diff --git a/.gitignore b/.gitignore index 5be2119..ea85efc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,11 @@ /.vscode/ /.claude/ /.agents/ +/.playwright-mcp/ # Node dependencies /node_modules/ +/modules/mcp/transport/node_modules/ # Composer dev dependencies (тесты). composer.json/composer.lock — в гите. /vendor/ @@ -20,9 +22,13 @@ /tmp/ /uploads/ /storage/backups/ +/storage/importprice/ +/storage/imports/ +/storage/logs/ /storage/releases/ /storage/runtime/ /storage/secrets/ +/storage/tmp/ /storage/updates/ /storage/installed.lock /sample/ diff --git a/PACKAGE-MANIFEST.json b/PACKAGE-MANIFEST.json index 4906586..5a817cc 100644 --- a/PACKAGE-MANIFEST.json +++ b/PACKAGE-MANIFEST.json @@ -2,31 +2,33 @@ "format": "ave-core-package-v2", "product": "AVE.cms", "version": "3.3", - "build": "0.32", + "build": "0.35", "package": "core", - "built_at": "2026-07-27T12:48:15+00:00", + "built_at": "2026-07-30T08:43:49+00:00", "installed_modules": 0, "bundled_optional_modules": [], "optional_after_install": [ "setup/" ], - "core_fingerprint": "7e65e16538e861c03bc15322b9642d07f75830abec0a852da54ce22431887dc5", + "core_fingerprint": "713dd207dba2d9f7242b30de3fd39a08a2b523cabd615a69873c3b3c48a133ea", "files": { - ".gitignore": "e2c156d23abc1be6e5a61c9478bb3f1c2b31eea9c7ff2f5e8bd768745638bd93", + ".gitignore": "9aa78fb183d01611e3ce9789d244d09ffb862c6ae718438b2132a7edd53a16ec", ".htaccess": "71c06b7bc41b09c347a562ca72768acaa0ee1ef8388c749fc67956c793d8a826", - "README.md": "9660a68a4a9f99436a32e219d3cabd5b373caecd7b41404a637d95a4626cb464", + "README.md": "7d3f3a226250482eb09aab8188b88c728145a632d7cafe6d742b6003a0ca4d8c", "adminx/.htaccess": "d03ea2d2972af8b7545a2d57c6253f303d15adeb30b775e161d887ca5ff73378", "adminx/Support/AdminLocale.php": "a7fe84d86d65c9f021106172b1789c8bc6221c36b2fe39cf883ca9a505e3e8ad", + "adminx/Support/BulkActionExecutor.php": "82859d10eec39a53091ed46604d6819e7111b7d879e5f0f1595c26f8a7ff1a60", "adminx/Support/CodeEditor.php": "51532980b1990e595b796d884c1a8bcc46f77f88344386e5153b4bef4cae973c", - "adminx/Support/InterfaceSettings.php": "43280d104e731781213f7af119ed21130e8b29e103fbccb77ed01a1ca7441b8f", - "adminx/Support/ModuleExtensions.php": "e1a0407638b16a773bfe414c214015aef5bcd6cb4cc6fdf772c1fd1ff43ad086", + "adminx/Support/GlobalSearchRegistry.php": "7722b193cd60161a29d8fd166c4445294528eac5336b2810a1d9c281347a188e", + "adminx/Support/InterfaceSettings.php": "e42cdc1157d8a55760278ab46d603316039fad0680eadf7c95824984f45f63ea", + "adminx/Support/ModuleExtensions.php": "36de2286496c8327ee22d806f98a6bc9521a2d81cd1bb4182122c7925ed8d0a7", "adminx/Support/Notifications.php": "ab08805d571cd3d8a4b2a09fdc6ce96fb4c7b5ec75691322cf8a4e1a142112a3", "adminx/Support/PhpCode.php": "5cdd56c7deddd713dd06412b64da729c8a411aa99f46958fc29121055740cd07", "adminx/Support/ReAuth.php": "f8c2146fb4b8f03b7d0cd2f3576e3f39fe2749823c5d6f1dca494268dcaba303", "adminx/Support/Roles.php": "6be335dd643317f77bd7c2db92f25895e601fe72818befd62f6aeb3ecac9fce7", "adminx/Support/RouteGuard.php": "e9e20c097c0db354851546585be72bfc71a5d14652871f8bd1160e845d4dab1b", "adminx/Support/SavedViews.php": "4548ca129f270184e61cb670e7f44a1c103099a6a6c2820bed1a11bc18f227c4", - "adminx/Support/SectionHelp.php": "c12ff274abc7f508d7ec28f02c770acdedb62390e1661914ecf740a3ea8b62f8", + "adminx/Support/SectionHelp.php": "75388bc96c0b43aad13befdf22884401b05361fc1d6dd5d0f1e85e00c09d2bdb", "adminx/Support/SensitiveRoutes.php": "8d40c429d6abbc1416e3797908672e551d2b3abe64cb1763c6c410e7edb9dbce", "adminx/Support/SystemFeatures.php": "f18ada623710f8c3efc6588c037227f6074e75bad9a3820aeef60b4d8d4b20a4", "adminx/Support/SystemHealth.php": "286aee452e90f71ad64437bd39d6dd985d312eb85bbb711490d3a0b631a16aea", @@ -34,7 +36,7 @@ "adminx/Support/Twig/AdminLocaleExtension.php": "1fb6c1d1c35d74fdb4a578cd15b3f19441725db6f11b336dc6948ed982842f3a", "adminx/Support/Twig/AdminLocaleNodeVisitor.php": "ccf387680fe8b0e11bd0fe0d23769be8b7ed9afc363cb9a802db5c4aeb7d405b", "adminx/Support/Twig/AdminTextNode.php": "e51d8d3ee49411f42c8a874cd283f60c1fa0af6506b34568a119ebe2d18d7268", - "adminx/assets/css/adminkit.css": "4c8371e04dd1d31058f9a17e4ab741665e94aac388d7307b8115ca613c562c5f", + "adminx/assets/css/adminkit.css": "15409efc9387120866479c3351a4a291ee7d8207694a84b228fc5d35fc184cbe", "adminx/assets/fonts/NunitoSans-Bold.woff2": "2d48580b7e1be60fa94b8092ebe86471fe0adfe5ca75242a7af2042ac9da825f", "adminx/assets/fonts/NunitoSans-ExtraBold.woff2": "66612ee9d1cdc341c9eec8a43104bbf515e7cc52821558bfc7dfcf33c8cb0cc1", "adminx/assets/fonts/NunitoSans-Regular.woff2": "878adc25278872933f004783f0ebbe092cebb3f9161aa30cffb2b9a8a07db040", @@ -42,12 +44,16 @@ "adminx/assets/fonts/OpenSans-Regular.woff2": "a26ed465673123933c5510730a7286f49016a40fa94062021d1dc4c9bfeddbd3", "adminx/assets/fonts/OpenSans-SemiBold.woff2": "7d963e1d2f61c11c2e8bd2eb528777b5a55569708d50a7ee7bd097d7fafe6da4", "adminx/assets/img/logo.svg": "685d9bf037b33fbcfc200947d5051c5325762f8ab8a29ac9e1e587076321e463", - "adminx/assets/js/adminx.js": "dbcfc5ea3918f551e7d1b9dfaeca6f2637b763403434e25d4d17cc60bbe91162", + "adminx/assets/js/adminx.js": "be65a81e87bb6580c0f3b59514c92627611ad18ba62f090a1600ed5fff20b63e", + "adminx/assets/js/bulk-actions.js": "d7ee80678e6703aff2418a4679ba8e5027e7220cb089848bcced6d9f100b3aae", + "adminx/assets/js/command-palette.js": "f62f0bad5ddf2b6e813e7eda094d73df63dce09a6e581025971e8f10430e8d9f", "adminx/assets/js/editor-codemirror.js": "90a04c219e84f5391a40b0d1fc807ecfe4ca582a198bc202bdc5aa6dccec51fa", - "adminx/assets/js/editor-tiptap.js": "9bf4fd26e441bb5e53aa73ec68fc8cd481399d0660446cc6e27cf1521711092c", - "adminx/assets/js/media-picker.js": "cc2374621634fb4430c066f0318cee326becf724cb4a5f9810b325740a903ed3", + "adminx/assets/js/editor-tiptap.js": "b924d1deccda0e85d947ec936ead0d32f85aa5517893d197316f75a614ac5092", + "adminx/assets/js/media-picker.js": "eec036a03bd1d0d7995708a15f3337cc6e5d2e8ede7d5be3b0c5eeaeed782ebc", "adminx/assets/js/saved-views.js": "30c02121634f6f035dcff6a730e6fe300a1892fc9321971f142dcff600802f30", - "adminx/assets/src/editor-tiptap.js": "5639dd0d6f844aa4066fa89833b4e7f63ffda2b7bbca40e3edef8959af02bf57", + "adminx/assets/src/bulk-actions.js": "29bcc568366ce4a86a71b12a0f8be050bd71a9eabb0f65af6d9ca57281db9201", + "adminx/assets/src/command-palette.js": "ff86b0dc0f1ad59d551af772643dbe094cdfb2367d402eca49fd1a4c5b1bb9db", + "adminx/assets/src/editor-tiptap.js": "e4cf6d82b1759708e9e2df5bb54136570e572a99138cd57f6eee7f91257ed7c4", "adminx/assets/vendor/codemirror/addon/dialog/dialog.css": "5df690d771f12118348562dd2a90060433ae2edeccd0908abcaa4480b1e3e468", "adminx/assets/vendor/codemirror/addon/dialog/dialog.js": "1eea0e500dce10d8594d8d681a91d1b71a4209355c9c29885e86e26b2d76685e", "adminx/assets/vendor/codemirror/addon/edit/closetag.js": "700b8aa7c46e040287e32ffd1edb86a42d09edc5b2a9594241590b154c0232bd", @@ -132,17 +138,17 @@ "adminx/assets/vendor/codemirror/theme/zenburn.css": "47780bcc6d429d434392aed5b63c26efed6bce3d34df1158573700ee189eab2f", "adminx/assets/vendor/tabler/fonts/tabler-icons.woff2": "cce741fcb6c526dfea3c0d48ff486279ac723617cd5b4de2bd5d726e94cef477", "adminx/assets/vendor/tabler/tabler-icons.min.css": "676509f34c06d34b75e4227e4c36105e5d220b458f609fffec247f20a8d72a8d", - "adminx/core-permissions.php": "9ec2e97e40c625f1878249232db60569747098dfb6c81fc0a3654d09807e6daa", - "adminx/index.php": "493cb39959a6b58903bd7dd470930b8f69ed859cf596cda1f8076b08579e97b4", - "adminx/language/en/client.xml": "f80a1538b75c0a727db745f21dac354c99693f13a952a26e36d7fe2687a54e59", + "adminx/core-permissions.php": "084fbff2f7625f0e1b6a6a15d00fce536212adbdf40f6a7fa2eef34cab54394b", + "adminx/index.php": "212b324ada416cff2da4eb876976411eab53890a7ef60784136ffdf4fa60decf", + "adminx/language/en/client.xml": "de712c66a939fcae607c087e0941dfbf824152a41be85aeae0a0406cc0ef3053", "adminx/language/en/common.xml": "e5f9972786f1d2a8e05f86913b07c361b82811f99cf51bee6d38e9ed275fc995", "adminx/language/en/interface.xml": "80ccb298e1ae8ea31c533832072b99c1eb1369871f6d1b357635aa59622d9d52", - "adminx/language/en/runtime.xml": "3d96eaeb04460caf6230d087cd23eb29878a5b9d047556fdac2a33674105728c", + "adminx/language/en/runtime.xml": "68b00572d69484240e1716007ce64c2febe66cae4e021b09c428e758de990ae2", "adminx/language/en/section-help.xml": "f82ba8d3b78d06d26b92afb81e036b241c8dd3ef9e4ded6158a0be10491e90b9", - "adminx/language/ru/client.xml": "841a585b544fbdad086db647fb8d0a64678a5947b84f10bd9fc8a10efdadaf78", + "adminx/language/ru/client.xml": "b69308cbe26e7da2c10084ce7163e3aacb24cf35447a70a3474963ab0a29ff77", "adminx/language/ru/common.xml": "d27d863bf82591dbe857317d97a888a23980c87529e2b4c1d4125a6c0ba20966", "adminx/language/ru/interface.xml": "03fa6351745d2e47c2b3407da65c8b7b048b8e862409dc9bb934e939b9439c75", - "adminx/language/ru/runtime.xml": "35c0d0a201b45a83169b54fdd4633b4b7f254e68f25e8b2872b3d5aaf417a363", + "adminx/language/ru/runtime.xml": "3c411a18695af7a63dd0685a8351ac3a51695aea7d1fa2550fb24cc9e3e82be6", "adminx/language/ru/section-help.xml": "e821c8589580e4fb898f6f7352691f81348303edab99c338c942511c9bfdca65", "adminx/modules/Auth/Controller.php": "bad2d7fe5f7eaf4b7f22661945d95df5a3201498b2920e17de5ede6f9f560686", "adminx/modules/Auth/language/en/interface.xml": "175f757bdc70cb47353a379a6a2303d4903ae7d446fd9a1362919a35517d11a2", @@ -150,13 +156,13 @@ "adminx/modules/Auth/language/ru/interface.xml": "f8d95a4a8039dabdca7d5f3095feb4904714225b1a285527fd43b0cab65bcf79", "adminx/modules/Auth/language/ru/runtime.xml": "94f025e2ddd2327ced6aa4740dba80025ae1d71b08f2ce6bd7de9a222277e058", "adminx/modules/Auth/module.php": "da1ef0924fdaee46a2c1333c43ed05d3afcb02ca16f1994b0eea840a657a4f12", - "adminx/modules/Auth/view/login.twig": "9848bfbd9da64798c3ba4508dbf7bd413541c3e6870ea95fe3bdf07d12133b95", + "adminx/modules/Auth/view/login.twig": "2046a79080fe3f609b88421be5cb62c291402b9717e23b14d8cf93b3c6aa0c18", "adminx/modules/Blocks/Controller.php": "5665327885171e07f87823b473f97e22f416ab896471a25ff7a2dff498a30d22", - "adminx/modules/Blocks/Model.php": "1a6d971f4a777e3f91385271ae8e207bacb10cdc1e236921f43a9578f6f9bcb5", - "adminx/modules/Blocks/Revisions.php": "b67cfbad6830a30dd7f99f5dc4108b33f2bc5d40fe979cab6fb03c3c8f749577", + "adminx/modules/Blocks/Model.php": "69dcdd7488f308349741bff0911066c5f1ceace23216ea408fbc79da4a73bc14", + "adminx/modules/Blocks/Revisions.php": "b0ff697364a5573c221d8a85545970c8f554af2c352bb51ff7e98930d4e7f91a", "adminx/modules/Blocks/Syntax.php": "9b2eb8d40171633cdbef6cf21545958f801f4983d62176e9256bec8e4763a624", - "adminx/modules/Blocks/assets/blocks.css": "a5451ed2acea3a5fd42771188dfa8c85b6911645699e0c219fda519b6175e2e9", - "adminx/modules/Blocks/assets/blocks.js": "efcda86cd75c8bc2bce2b9ebd32e1a1c51da15119f65f7aa63e45c8d7dd73970", + "adminx/modules/Blocks/assets/blocks.css": "13ec7cd2b5a9c2abda0ff2d1419fed50dc1e122edf8ec278e11cb12d75b8ff38", + "adminx/modules/Blocks/assets/blocks.js": "8d6633fa870026c2eb014878bcb985d5807fca8040876364be7b3ca34d8fccb8", "adminx/modules/Blocks/language/en/interface.xml": "6b358b561f81febe1a3284045ff20132737e624a04b2481b51681e258f748e22", "adminx/modules/Blocks/language/en/runtime.xml": "cd76ddcfb5751496dc4cc160dc2c6f38ae5699a3a5c0041b5fb214fa434f04e1", "adminx/modules/Blocks/language/ru/interface.xml": "6048c75a95fb474287cf4903fe1b82f2608741b355134be885eadf511da5b656", @@ -175,10 +181,10 @@ "adminx/modules/Blocks/migrations/012_merge_visual_blocks.php": "5c9cd6efd66a027134621859da1059b2c403bacb732885d6b405699cbce8fc14", "adminx/modules/Blocks/module.php": "e196ec71bc17da48f9e1663f74db4a7de8fcd28d0f32a3f36beebd8247656322", "adminx/modules/Blocks/view/index.twig": "39f9225b6a95c65549d0c861afb7db7879ca338c251290653dded6ab7cb715cc", - "adminx/modules/Catalog/Controller.php": "e9f7add9ea4724221259589d1a861d0233895a150c5a14942325a74a46ac6018", + "adminx/modules/Catalog/Controller.php": "996c5454307133d98a1334584d917b2157f62c2fdc0812683fab93b7ab7f5988", "adminx/modules/Catalog/Model.php": "e7c7cffd91754e04dac322c31a64b420739c671079a7f8e7effb550f5019ee13", - "adminx/modules/Catalog/assets/catalog-drawer.css": "07ac184a0fbc18954117d5e4d559574400cf6cb7d9b511d852120c00cf17a059", - "adminx/modules/Catalog/assets/catalog.css": "5587eed0a0c7d22fb8e841b238df2f9477857b0c75d90f232c5816897ee6bfce", + "adminx/modules/Catalog/assets/catalog-drawer.css": "c16f11497fafb379db09823b25234f06735341f5f4c8f54026b96de689d894dc", + "adminx/modules/Catalog/assets/catalog.css": "49e7a33fc444f8330806e1cc5cbb0d523b673c64faafd25dbf0fe33067643efd", "adminx/modules/Catalog/assets/catalog.js": "24c1be107fdc88889a0b537b3cc93b32dcf0d83bfe97fefdf9bf10012a132b31", "adminx/modules/Catalog/language/en/client.xml": "9b156a155a1e6f1a2024f4be5ab8d1051b1ec2adf4b9a0ede71afe11e6d4558f", "adminx/modules/Catalog/language/en/interface.xml": "1007d3fb9158846dd36f519d13909502e16d3c0a9ca2c49dc24bec088c487ea7", @@ -188,7 +194,7 @@ "adminx/modules/Catalog/language/ru/runtime.xml": "fe59945f4ceb2bf2d85699623369e7e5889613b8cecd8551a390c78023531d14", "adminx/modules/Catalog/migrations/005_add_catalog_purpose.sql": "006ff237e25042a4fa9b9f74d5079741eeee1aa52db0cd996ea4c8b817e6700f", "adminx/modules/Catalog/module.php": "6d8b0d0bc67d314c3aee6f5813c36d32b6f4bc0de884515e13b059927c3aa9ce", - "adminx/modules/Catalog/view/edit.twig": "6ebdc547619025da9fb9b177875c715b8d9bb5ee6ec3e059e309c1ef7483f931", + "adminx/modules/Catalog/view/edit.twig": "eb428848b39c2672c5c25dcd04cece8325230c4b6ed15a3064bd332f31262899", "adminx/modules/Catalog/view/index.twig": "754c7c4dc81176e8c99b3d9e8c1fda2f62f03df341c3df1f7393f72d43c6f8e2", "adminx/modules/Console/Controller.php": "af0309a50d7f4366279be8ea12959cf7718a3c68b895a65ec5846554fd6a4857", "adminx/modules/Console/Model.php": "53aad480b8d4e5d68c4e9345d219ea5cbff6d04eee7ce4aa15deab6f00dac1cc", @@ -206,7 +212,7 @@ "adminx/modules/Console/view/index.twig": "c64fe9632db751357ed98cf2ddca524001e71200297e1b50f0d5c827483933af", "adminx/modules/Customers/Controller.php": "1aebb30a2d85343ace1719fca451a105cfe9e79cd3fcde6dbf8b6d907c2dee23", "adminx/modules/Customers/Model.php": "df097775eacfe0aac5c3b7ad4fdab7ff0816f57b77f94ed7993acd221725a3e6", - "adminx/modules/Customers/assets/customers.css": "adabc3a85737d66cb4042a4262f4ed703643b469c379468c5b7e626131fd2f9e", + "adminx/modules/Customers/assets/customers.css": "fdbd5c97456751080d134f8a97fc85b116098c7a29c4933600ac23fd0e33adbf", "adminx/modules/Customers/assets/customers.js": "babfd39619f4a45717e98b15dc3ec29976f7206a805880b0735c10a5de601b6a", "adminx/modules/Customers/language/en/client.xml": "21310e86bb84cc1fbaf65fad3f09bb4d058e820d919a9af6ae5e2700c2e8d1b7", "adminx/modules/Customers/language/en/interface.xml": "573a7ca126beeaff77a8865d6114dac0429b588d8061f939c5ed7af316d4070b", @@ -224,7 +230,7 @@ "adminx/modules/Customers/view/index.twig": "5f19c30e03367b0bcbc83256d727eb19ee045ab3475521531faf486a4d446fc4", "adminx/modules/Dashboard/Controller.php": "7e6ad0610df7581bb010cee574d8d093c4737af439bd2b0763142f50ddee1c54", "adminx/modules/Dashboard/Widgets.php": "56fc7ba7c1f9e30d7eae8c61524b6441b1c706a2cd56b1db883bb8bff7186eed", - "adminx/modules/Dashboard/assets/dashboard.css": "b38927851888ccb5bcaaae3c22243ff7d727b1ea2a585053d935a404908e4900", + "adminx/modules/Dashboard/assets/dashboard.css": "8e28b56cac2e55d9b57304ea6e265fd94a52400674c082fbdd78f1eed1872424", "adminx/modules/Dashboard/assets/dashboard.js": "ac683db96673fb4f70b8cb2d23ab7eda2251f96cc2852223f5ee68bd11a911e7", "adminx/modules/Dashboard/language/en/common.xml": "10445e46f7445366125f761c2d29567637d6d2c9ba1e9766136280ff28c3d465", "adminx/modules/Dashboard/language/en/interface.xml": "58a20edaaf485d5a3131d7684974c2d18bedea408a1e28f5f47fcd3fae025159", @@ -237,12 +243,12 @@ "adminx/modules/Dashboard/view/widgets/recent-documents.twig": "d03f36f047574f8ce57fdcfd79b26d9b20ca01dba69ce7c71a3e9a277f0105d0", "adminx/modules/Dashboard/view/widgets/shortcuts.twig": "af165425ea7889cdc3edbd72c7f0dd9dc5c4c06486bc4ab37816112cfb696718", "adminx/modules/Dashboard/view/widgets/system-kpis.twig": "52dab540a189443b76811ac4b457a566cf90134366749aa9c7d82e2d23ffdaa3", - "adminx/modules/Database/Backup.php": "17e115967f1468803c07a1514df84ae9111b8f2b0e0a936efb7ce0312dc88458", - "adminx/modules/Database/BackupRestore.php": "370eef15faf49ee0e8e1f24fdac89a38ae2833a24d9c02c6778786de03ee8a41", + "adminx/modules/Database/Backup.php": "cab7b7d1a8ab9d2d18ece85604670458a851665b0af64f7c37d5763cc658c464", + "adminx/modules/Database/BackupRestore.php": "1fb34abdec27ed24665ec0749f4ed44167f3881d3477129d5d4de6c7b36d6653", "adminx/modules/Database/Controller.php": "c3f59beb5874b8959a5a37c49fecc9b6b48581bf20284b73290c44fb91a14363", "adminx/modules/Database/Model.php": "52c23131d07bc798fb2a3489f91ae909985e60d2965e3cd5fbaf5ab441d85477", "adminx/modules/Database/SqlDumpReader.php": "f0b3a501d0afea0ae50d35b43e901844e14d9faec286738956dc6f2b1c5ea4b3", - "adminx/modules/Database/assets/database.css": "fafcc546e3c5d5b72656a17e4a150ec9eff37e91b5ac0c8a1f0058d475ec8295", + "adminx/modules/Database/assets/database.css": "aefc5ac895e05190df663d2a7a384430bcbdf2cef92ef1ccd3b819524a20cdbd", "adminx/modules/Database/assets/database.js": "f54e2756a54eedf5654eee7357a022478988293c9419bb7ad024eb8f69ad8c0a", "adminx/modules/Database/language/en/client.xml": "4464d65adfd150e597436cade1d36b0c0436654fb4c710218fe2fad117b12369", "adminx/modules/Database/language/en/interface.xml": "da319d6e408fd13179bb4094bff6cab3c72880f691f2e80a3ff13245144014d7", @@ -254,21 +260,30 @@ "adminx/modules/Database/migrations/002_module_lifecycle_recovery.php": "779b04c8b35f3d81657848016536a9e3ef70fb76dd3ce5b1ba4b4a72166f7a2d", "adminx/modules/Database/migrations/003_document_fields_text_composite_index.php": "541a8df87dd5fec6946f26dcca88f78a2bf5e09608f9300baa8532202020ffbf", "adminx/modules/Database/module.php": "61c12758ca1a7b59907c13cd9dbcc39ce771fc671edba665d7b6d4bcf82c9aca", - "adminx/modules/Database/view/index.twig": "e1b18f0ea24c95683477c7ea28bd6094e8589e6dad2acb627434ff7241d7a410", - "adminx/modules/Documents/Controller.php": "ae8fe6ec76b2521fcbd336913a3de9750dbe07f80b568adc45c4f228f66d0259", + "adminx/modules/Database/view/index.twig": "566ef7db0a78b98ab1e41750dc364c073735404c62576f7995ddaa4a6d51828a", + "adminx/modules/Directories/Controller.php": "bae664ccff5a63a215fa1b59b721d0b0a4055ac3633319476d09889ead0d7d27", + "adminx/modules/Directories/Model.php": "ead423e3d0a07badf5a773780ee37d92394240fc753c10fad274a3131db7c2ce", + "adminx/modules/Directories/assets/directories.css": "6250526e076f3da647c1e696ce965ae2d9d3ddce890b8cf022af4b33aee18e1d", + "adminx/modules/Directories/assets/directories.js": "8e0b59128ab92a07731009190716ad3dc14f99ff7ed2bef299e1df645a4b82a0", + "adminx/modules/Directories/migrations/001_create_directories.sql": "d1f84fccf75892fc1dcfe6b770112101e233d96d9f4866a08d1977646a690530", + "adminx/modules/Directories/module.php": "562709b6a6ca8bea3e2e05d1dde05af0ce7e627e94d52df3b161349dadeb90da", + "adminx/modules/Directories/view/index.twig": "0bde23bc578b9ffad3c47534c304c0016b661a36588d6d3c9ffb92e2daa388a0", + "adminx/modules/Documents/Controller.php": "8cd40c0e942f7eaef30af8afea990746014df03c4063a69fd6e34019045fd72d", "adminx/modules/Documents/DocumentHookRunner.php": "7e0cd24ed5791b6d5010e4a05351b709be09b6df1e700a3ff06685b9078b2b1d", "adminx/modules/Documents/EditConflict.php": "ebc3ebb92c3fc3ccf3909d2ff517014fc888ee20576a520b4979f68bde632b97", - "adminx/modules/Documents/Model.php": "ec5b52309ee149123ed333f4373c5c7e94045701314e887a1ae870a4d7667058", + "adminx/modules/Documents/Model.php": "60ad2c0508a6f1ad1e090dc54cadd19ef864fa9657558c5d82291dc30796c15e", "adminx/modules/Documents/Revisions.php": "5d40ba1ae4c9563e14464f795b054f9d4edb05e02c8a719bd796126054616132", - "adminx/modules/Documents/assets/documents.css": "f09b629990eb6beabb6909ed239ac761a2da2a6e902d0b588506f3ce76a0a477", - "adminx/modules/Documents/assets/documents.js": "acfb3b096decdb6b725118c9013f1098878f13737df0f23c0df2744289260577", - "adminx/modules/Documents/assets/redirects.js": "7297896b340be6cabecfeb220dabca1d63c8040e2ceaf0d3a816f955c3ef45eb", - "adminx/modules/Documents/language/en/client.xml": "75c2550fd7ab123d14efda13c575d474b3989216c9adbeb70d08b3f40e6523e5", + "adminx/modules/Documents/assets/documents.css": "c7898e92c20f71c155928eb4acb298a3975370269f449960c7576776c9134b9c", + "adminx/modules/Documents/assets/documents.js": "d1af08bd3869764c103cd97880d8d82cba1d233fc4b5dfb01885064713ed6750", + "adminx/modules/Documents/assets/redirects.js": "95ca395c6b4fd3814da37450c27da351e75f509e26421c975dd8e0ee5f657efd", + "adminx/modules/Documents/language/en/client.xml": "10de693182945b4c5347aafe508c212ba193acbc1384812aa2c802b5298bf908", "adminx/modules/Documents/language/en/interface.xml": "f3a30b3c565d2e01c841c843dc529fed9844dc2e095f90b7902008ded7214ed2", "adminx/modules/Documents/language/en/runtime.xml": "7009fa27d67ab168fecd9d9bf182216dfb0397d9c006eb6563ee3c464daa5b5e", - "adminx/modules/Documents/language/ru/client.xml": "b5c50b64528eb4a7cc324222e6e51d88889d892be2581baa6c68fb877916f9f9", + "adminx/modules/Documents/language/en/zz-interface.xml": "839ea37d585ff10d7b819a4fcd4bda876c94d51019489cc8d35af15fbc905f2b", + "adminx/modules/Documents/language/ru/client.xml": "14f67aa847553269adb37efa6bb408d866e30f8db0eee92810564e0f7eb67305", "adminx/modules/Documents/language/ru/interface.xml": "718386366f913c590c2bb881f49ad3b7d436de82faeec30655a7c2f9b14d58e0", "adminx/modules/Documents/language/ru/runtime.xml": "6c3e9a2dbf862bef5a76ff0bb600084fcd9d5d1a7f0cbfd02377242b74fcd759", + "adminx/modules/Documents/language/ru/zz-interface.xml": "2d819845ff2ae5246203374a685f8a5ad881ba9067166161eb96d5c9271026dd", "adminx/modules/Documents/migrations/001_correct_breadcrumb_title.sql": "e2b612f19cbc2c419d1a3d2c684909b4055811a7121f4debdf6b83e99bd214cf", "adminx/modules/Documents/migrations/002_correct_legacy_breadcrumb_title.sql": "1c1a2880dcc9ea69ca907767a6333a562d6f0f30da159e35c55377c0594fb8a2", "adminx/modules/Documents/migrations/003_create_document_api_tokens.sql": "15432eac6ce1b6f867b5a2ccdeac28900ab2382d0dc775ebe13e305e93603b87", @@ -286,46 +301,51 @@ "adminx/modules/Documents/migrations/015_document_creation_presets.sql": "a123f8867ab6f6346002d08317acac37062c2792913ad14102c81812d45571bd", "adminx/modules/Documents/migrations/016_reconcile_document_content_columns.php": "06671bb26bf29479dce24888fb3712c0d20e2aac5b3d70196604ce4097c875fc", "adminx/modules/Documents/module.php": "9fd49d13abaa566c76d4d2d7b0e4e2dac2b7d58d1f27e3545400b3a1c900ebfe", + "adminx/modules/Documents/view/_product_panels.twig": "e07898ea6a7cc0004ac7545b689afdc6b474e6d8b24357f5c5190b4ca5589142", "adminx/modules/Documents/view/api.twig": "60e004a449d5df2b39e24a073acdf3622b2d379e668bca5834d59bf9e3e07294", - "adminx/modules/Documents/view/edit.twig": "c3836a0fa03a5b8d9434e110f21ba24bf8fb9bbbdd3acb20e6fbbdf472cb4c80", + "adminx/modules/Documents/view/edit.twig": "206ad52382062e3603601f038638f26c5602a5a01b0494eb80c0902d8f266f6c", "adminx/modules/Documents/view/index.twig": "b6f005bbb66550e43f59d75b8311f290e6c728687473a1a7da2dd74af56b8a7e", "adminx/modules/Documents/view/redirects.twig": "0d8e6c0431c3cb4571a40f021ad3f55a9c2df1092b500fa3735258bb80231d7a", "adminx/modules/Documents/view/views.twig": "07ae0d509df51aeb551ae5aea2a19766c1cb00fd255ab3d9734ffdb7e55764c8", - "adminx/modules/Events/Controller.php": "6713fbd79269d2bdd159a15b8f77ac1de7cbd40ac4e487778bc61a4901e85026", - "adminx/modules/Events/Model.php": "bf62126857bc4e43c1c6c594b0f51bedeee76a58bb92b990be43f925c2eca4aa", - "adminx/modules/Events/assets/events.css": "8a21ffdee25078635c59c79290dfe14b941fd63859fffd2d17d13fd5b488fac1", - "adminx/modules/Events/assets/events.js": "b07c0e4433ca6c0ac1a5b8bbabd7e025f23833a2443c03cddc0b76d6be2765bf", - "adminx/modules/Events/language/en/client.xml": "e198b73ef8a84a7606f98ca6caa8039edfac2bcf3b4652c56b8dd9460dbd193e", + "adminx/modules/Events/Controller.php": "c57fc25e815c47c14626371d044079e6711f0a4bce2004d2b9256db62f0d0e85", + "adminx/modules/Events/Model.php": "a3a09370aa47766c7c4e70ec002af98a13bfcad3151b9ecb96a6ffb6cddf7ae2", + "adminx/modules/Events/assets/events.css": "91457d384056a6ea96bdc6bf012c033c19e0cb21401460ae9b0f9398493258fc", + "adminx/modules/Events/assets/events.js": "d4c6cc636473ecd81bd1551c37df5b96434e7e35fbb251f93b3647a25f52d336", + "adminx/modules/Events/language/en/client.xml": "ea0be743665d923eed666b97590a63ad078caa2d439f18ccf78c22f040ab9e24", "adminx/modules/Events/language/en/interface.xml": "8ba72b194174cff23b1678a34930d0fcc8fbb51465a6e0457cd503c2d0119dc1", "adminx/modules/Events/language/en/runtime.xml": "e6d14b92215da7d85bc8460f2e42410c0005f51c1245533b9d57c62fc211a457", - "adminx/modules/Events/language/ru/client.xml": "ca18c62786e33a251fd3c903fefb64d0fed84a5f8a62d23299ab81b8f51284c1", + "adminx/modules/Events/language/ru/client.xml": "e7799f20371afb59047dcc0b640aa43a5c047761a7627f98b56b4e8d93dad612", "adminx/modules/Events/language/ru/interface.xml": "701b5862e628fd5e4d64ac4df5983d1f40b8aa1b2fc67b4eccde0a1143338e27", "adminx/modules/Events/language/ru/runtime.xml": "339a6e6c117e3e14e871acd38ab398ec4f54f0bee3785a9c3f5d90048899a9ec", "adminx/modules/Events/migrations/001_materialize_event_logs.sql": "59500ca428021c9dce543ff9a7711e2b729f7e9566ec6277be09939e78b9ddd0", "adminx/modules/Events/module.php": "d506c314dfc71abe7216ec80243f46549a833a6cc3371333d1e7a6435312c46b", - "adminx/modules/Events/view/index.twig": "eade5c87c3cb76dd8ea4795ad72927848a40fb0f042e4938bf31d785f8c33b4b", + "adminx/modules/Events/view/index.twig": "7057cee5abe6ace177509c54c2d1736ab88b96f9f17f6db2bec0553b4eb1ac5f", + "adminx/modules/GlobalSearch/Controller.php": "6a109bbe2ca833df90138af056cb6cc32c313677d3bda071b3638448ca7e33fa", + "adminx/modules/GlobalSearch/Model.php": "5cce4bc4358c2b7b0f54e76f77140470dc7d6413176f37e97808553f11aeefcb", + "adminx/modules/GlobalSearch/module.php": "b7b5d9c9179500edbbb82a7ca0583374ac2a13bc8aa3f2eca5c6b9242d6ddbeb", "adminx/modules/Groups/Controller.php": "37959bbc4cf73ea2968616c195763fbd1bbe14cea90c01d4dae5fad2970803d6", "adminx/modules/Groups/Model.php": "889ba3e4c93ee59ddd6515f4fa4b5ce4c82335e91b3dc6eb315dd095ce6f8fa2", "adminx/modules/Groups/Setup.php": "b7140a87474e343455fa81809f8f8f2dfe7e1ee3640c5f024a1bb06ebd783865", "adminx/modules/Groups/assets/groups.css": "6d16a57db9ab5f42f8722b52dcecefb02a51d68360b9855187dfece83b36191d", "adminx/modules/Groups/assets/groups.js": "1e8e6c9b183cae19587e823f037e2dec5e69aedf10629c555edeffe68369a64b", "adminx/modules/Groups/language/en/client.xml": "dbceb0c80a844d05f3c370dbc495a71142818c0c9e3ff94c33586bea89f07831", - "adminx/modules/Groups/language/en/interface.xml": "54469c9116673d33e55fb26426dae68ab864a64d3dd2afb99312ac6ac14f8bc7", + "adminx/modules/Groups/language/en/interface.xml": "ca7dfd681c7f1e8580914cafdfd259d5d3be84868e7ee97288fa36ab3f785de7", "adminx/modules/Groups/language/en/runtime.xml": "7ab07b963b2b29f046f6c363f04c1104de6d5914f5e70b6b8cd42f5770847168", "adminx/modules/Groups/language/ru/client.xml": "320b9b55d6eaf6f4d3643f3fa4675544a779540ec34b3b94e03deac6f57579b0", - "adminx/modules/Groups/language/ru/interface.xml": "d6175d32b3e971aa03f4f6650967cc261ca0bfc04928d5c7cbbedf536b6c1a8c", + "adminx/modules/Groups/language/ru/interface.xml": "6f9da63ae65ecc8890ea16dbe7e879bdf5520d5f74c0a6204285b19d580f15e3", "adminx/modules/Groups/language/ru/runtime.xml": "eea7d7d2b88befb79b50d00c89dc63d0bdfa95883483d9606ce0605c03c92402", "adminx/modules/Groups/migrations/001_register_public_debug_permission.sql": "a81a4843b74ad72aca16c8016b767dca248b2ea950673d99c6945d1b8b867fe9", "adminx/modules/Groups/migrations/002_remove_observer_role.sql": "310c6d1a9bfa5693f4a3f680f63279ea9dd040e51284d6cf5de9ad519bd2152d", "adminx/modules/Groups/migrations/003_seed_default_roles.sql": "75cda12582ae86e538fdd455d064eacfcadf5f4dc50e51838718f154d73ed750", "adminx/modules/Groups/migrations/004_merge_legacy_roles.sql": "3ca98bc7f0348d04594e42beb8768a6fde6a732d39e7528068efc0b89d9c5ce0", "adminx/modules/Groups/migrations/005_register_development_site_permission.sql": "0a87db84297b7a8b5959217b82b6dbdc88507a822b342e145ddb7d800f50a73e", - "adminx/modules/Groups/module.php": "9f3bd0191fba171c4bad40ada6274e91b1a9f8c5e4d994bd27c02a2a894b7965", - "adminx/modules/Groups/view/list.twig": "7cb21300defc1873604c0af45b98698f91708057cc0853caef6f66bf0277caa5", - "adminx/modules/Media/Controller.php": "0ecaaa26824e737b30aaa0b68f63e7405056a58e399ded779568109416ee7eca", - "adminx/modules/Media/Model.php": "12f4fa4eec705bcecbbaedfe07a7e25ef57062fa72e937333ade840557be1cbc", - "adminx/modules/Media/assets/media.css": "f3e04f005cd3930de211245f912ff35044ee3f5f2b5d86d7384b1dbd026d592f", - "adminx/modules/Media/assets/media.js": "0182e6b649a3993a5ec15f73a63ab89c330c5f3d1d901f848a16e66952615c9a", + "adminx/modules/Groups/migrations/006_normalize_control_panel_permission.sql": "eb58f4d6a59efe656359d003d81f995e989393d7235a3fae7e38c0733fe174fc", + "adminx/modules/Groups/module.php": "4f1d59f9aa351b18b111396d74001597ffefa744cd55240c7339fee06f009542", + "adminx/modules/Groups/view/list.twig": "48949134b29f8c2f62bef3d90e1438131e5dbc53cba84214e11e5cbed10040ed", + "adminx/modules/Media/Controller.php": "961186cbc05fdefae407d7af92075bc7871415769cd40eba7aebc5b6f52fb762", + "adminx/modules/Media/Model.php": "c23ffb325ba7d3f86b1088778c24ebc6860047fc19ba038fc061a10d121d2c0e", + "adminx/modules/Media/assets/media.css": "277e91e70607ef85c2a71a0a2d1598cfc3c87a70e0a3f45b73548397fef74278", + "adminx/modules/Media/assets/media.js": "1fac7fee094cef4f4726f92fd966b8fc02bfe7cbae960e75d9fc7cef901d2d64", "adminx/modules/Media/language/en/client.xml": "06cecdb6cacdd0871334a3da0a3c7be27d7f835e2a048d37ea31ae4f37469270", "adminx/modules/Media/language/en/interface.xml": "2eab0a0d6acf094c43c690e4c09683378a26dfaec7e258c23f3f7bfb1fc9e9a7", "adminx/modules/Media/language/en/runtime.xml": "8768433d736f0e8b3508b1a68de1281b2f841c2a9decbd8195603a31bff3a92f", @@ -334,25 +354,25 @@ "adminx/modules/Media/language/ru/runtime.xml": "538db360abf2d61e32d6476846173ea4f5115703021362314a9100df3854d6f7", "adminx/modules/Media/module.php": "6e5b38b7da0d2fd506900af70a8813162e3e6215fe403e969ab914a9c20ced1e", "adminx/modules/Media/view/file.twig": "c338f333ac0424c224e5d73d1e3ac1854000155f3a06d6137992660bb2f407b2", - "adminx/modules/Media/view/index.twig": "4dab58fac2cbb79d83c3e70bbcf6545dd375d8b2397b2a3fd1e85ec8d0b83ec2", - "adminx/modules/Modules/Controller.php": "a3b42def293e69921dc72c575146d6c5828127c66c53ce62d30a0cecebba5051", + "adminx/modules/Media/view/index.twig": "d718bcac68d2de3046c4d6a551dab38577d5a9a8ee6a9f8733a2a8c640531bf9", + "adminx/modules/Modules/Controller.php": "de3c800b32a05ab41dac31d4964242fb0635aef6ef32f69a332509159b8e755d", "adminx/modules/Modules/ModuleArchiveInstaller.php": "22f015a6695de9f4fea12a580adfb76837e59ee4e9de92c153782c5a40a97560", "adminx/modules/Modules/ModuleRepository.php": "651efb2eb73beefd4d486761d0a554d45725f58aab47bcb6e88d7d61a0b8b87c", - "adminx/modules/Modules/NativeModuleAdapter.php": "aa27011ea06d71b440476d23496c06d5a55f776a3c35c1c9e37eb44239616b8b", - "adminx/modules/Modules/assets/modules.css": "c5f0ce469db594c6706dd4e9766413fa49428572306f61603cf787afd9c29881", - "adminx/modules/Modules/assets/modules.js": "dcb0622e6a571f25d9478a937ea0d298ca2185624b87469b1ab3afe5483b0a0e", - "adminx/modules/Modules/language/en/client.xml": "0fbfd035fbea33f38e12163a97e463bb27e4f7feb27a93d41e1a703e956512d7", + "adminx/modules/Modules/NativeModuleAdapter.php": "607ed915ed6a956250b8625117084d9b5c46f149235e99457a514016d7ba375d", + "adminx/modules/Modules/assets/modules.css": "ba3f1f6721dd1e529903254abe8fd24e20873b6c00e0a2d2d2d7bcc65170d884", + "adminx/modules/Modules/assets/modules.js": "7ed1e03f0114ff942b4db057ca57586c2cc94ed55a2d10f5ca5dafa908acabb5", + "adminx/modules/Modules/language/en/client.xml": "f54084951cb19973f33c9a28576353a09050129f8305d47590465083c0130791", "adminx/modules/Modules/language/en/interface.xml": "21596992d04ddbdafc36aeb26d3451f042571166426a6a0007f698db9baf0007", "adminx/modules/Modules/language/en/runtime.xml": "df647071e7691a8dc3634568ec775e09886e57845834772177083724d7d93e56", - "adminx/modules/Modules/language/ru/client.xml": "01b635097b86fcf9fdb76979e5275a2497880d9e029a2bf2192530e0628b6bcc", + "adminx/modules/Modules/language/ru/client.xml": "176d4ebdae73e7533082676c2e56199d47a43336ace47fbeb31392adad26d212", "adminx/modules/Modules/language/ru/interface.xml": "ee33c8914bb44478a8801ae23522954c860a6ecb007b848bb956fc07d5be4ce7", "adminx/modules/Modules/language/ru/runtime.xml": "271e31a8ae1586ed045861933a6db150427c78be7560942185633f35cf90686a", - "adminx/modules/Modules/module.php": "173ff87577a9016d30cb9fa928d05eb4f532264d8f27c7f84aa443ffb6e20492", - "adminx/modules/Modules/view/index.twig": "2e9e19bd26082dbab628f979e8469a7a1cc6281901c2230755f44ad5a5d51ecd", + "adminx/modules/Modules/module.php": "d3954b60d4eef716ba12e4782999fde169210d1cbfd35f0e6b9887c22b3f77e3", + "adminx/modules/Modules/view/index.twig": "2ca402c0886f611129e884129f6e0b0022e14a8c385d5d4ad55abe49ee39f5c2", "adminx/modules/Navigation/Controller.php": "796d25525322eb63e0428ad5f9964ae52ff3d91fc9aeee275c9f854aeb99a5de", - "adminx/modules/Navigation/Model.php": "484e16c2c36d4880e7428bca45f8d50e4a838d40d58c98230a635c830582ca3b", - "adminx/modules/Navigation/assets/navigation.css": "030f3dc05b22ba5559540ae6dbb64b2842350645f14e834fdc15ff269d7ece78", - "adminx/modules/Navigation/assets/navigation.js": "2dec089bf9f9d4eeac8119e07f8ca7e0c0914f2175eec1549e48103c9577d30e", + "adminx/modules/Navigation/Model.php": "46909ee83e0aff609337ccd942c7a69765d734ffbb47bea66015f4a821f0c4b8", + "adminx/modules/Navigation/assets/navigation.css": "66d2ee424dcb1d2044c337498b570e1c2539c7148ad19975fc776e8f5ed94ffb", + "adminx/modules/Navigation/assets/navigation.js": "b9c23fdd643e00f6f28cb57f4d75f4687bbd6d7b32b76f04df4126e24440f2f7", "adminx/modules/Navigation/language/en/client.xml": "9a0ad0248941cd6377ada049666387581bea0dd76353d34779b4c1343b74ba50", "adminx/modules/Navigation/language/en/interface.xml": "5ec5a64240356c4b87f65baea911762e64e88dd652c1fd7c801079247d697b79", "adminx/modules/Navigation/language/en/runtime.xml": "341b984ede453a135b6e155fd29c08d9d5a2ab16e62046379f982d9a56dc4a81", @@ -360,28 +380,31 @@ "adminx/modules/Navigation/language/ru/interface.xml": "cd5ac333b85534be6a81377a1b40fbd449174cf2d1f019d25b3bb3f5ba0713a5", "adminx/modules/Navigation/language/ru/runtime.xml": "45d994c39fa7fa092525ed4a61d6775008f3993f4cdcdd615d61e5f15a749cff", "adminx/modules/Navigation/migrations/001_create_navigation_tables.sql": "43c937b8d7d7f5b70569c591db58ee8592386b988e8cf148fae723b1f68b3012", - "adminx/modules/Navigation/module.php": "1be9c185ff36f11d4d03aa93a75ca0b889f6884a006ec801ad9005527b69e235", + "adminx/modules/Navigation/module.php": "e7783bfdceac8787da92d10be2ffc38ed4a8988db4112e83faec8448c8ee4656", "adminx/modules/Navigation/view/index.twig": "870b617bc857aeb3838e76ce79f0a7fbb81ff299eaa0155767fef5b86fbdf65d", - "adminx/modules/Registrations/Controller.php": "90ef3b986abd7b5062cd02009a8cb8b174081fd217166c019666fcb94904d8f6", - "adminx/modules/Registrations/Model.php": "689ba41add40cd61edba2659742931c4a9256ec4caa98066996b17f34a50091a", - "adminx/modules/Registrations/Schema.php": "d61127fd2e8b5a9c53ac3c4a9f0348edacb9552674a3fd5708bdf246e6bb072d", - "adminx/modules/Registrations/assets/registrations.css": "0d7a49bd09c22a968c3b2d51148c54510c564cf63578099931c441197a54406f", - "adminx/modules/Registrations/assets/registrations.js": "fdb1647b7f9e1ca9ddf3bbac81dd6a3405af5616b878f24f51ca17c216f938de", - "adminx/modules/Registrations/language/en/client.xml": "a58b1540a3505be6b05ac2260914f9796c894b059086694ea722cd37cda3d056", - "adminx/modules/Registrations/language/en/interface.xml": "7ab5d6b95157c613e5d010570ea4fbd95c96d257267764060dca415fa23b7d77", - "adminx/modules/Registrations/language/en/runtime.xml": "69986681ad2573d277a63a9b87395aef2d82a3043cf7f3c9db5d146ce1be67dd", - "adminx/modules/Registrations/language/ru/client.xml": "665a6fc93d2dd3ac3c5453cab99fc655e3208ad06262c26c90758edd67481751", - "adminx/modules/Registrations/language/ru/interface.xml": "072128419f1ce3ddd7f4faa3d1605ec34e0368ef137ee2ab87e4b5d2dee5682e", - "adminx/modules/Registrations/language/ru/runtime.xml": "cd3fc8a2f1a1e0afb2476d29947ec29dd8d1586254ca7f32420c73316b8226ae", - "adminx/modules/Registrations/migrations/001_create_registration_certificates.sql": "e843c57ec87589057a7f63d2b2957afc68417d4dcbad255cf77d0c073916e976", - "adminx/modules/Registrations/migrations/002_register_registration_permissions.sql": "b905299b8da097a3706e6f93cc1376287383e77ae9825a255d566cb01da23e6f", - "adminx/modules/Registrations/migrations/uninstall.sql": "41ef0a6f74661e513314140e4f65e1b859a8c47c050afffa6696928e8e31788e", - "adminx/modules/Registrations/module.php": "b98ab875ae628efd3f729f84a1c9f24bd2d4f96e05664b5f455a8c60934ec7ef", - "adminx/modules/Registrations/view/edit.twig": "2e7e7859acd0da4be37990d96dd2fcc9224cae62e89d47f55803ad99d070c82d", - "adminx/modules/Registrations/view/index.twig": "16e488d00227ee1c78e1df68fdf671f75e96d188f18832482c069e4402851884", + "adminx/modules/PublicSite/Controller.php": "91233dfdcb4d893511f0c7f13db8673bfeace19fce2d303a82d7c4b849f4c00e", + "adminx/modules/PublicSite/Model.php": "48ae7a07d130aa6ac6df195ab12b2e2d8e00e31e09a4e8bfb914f40eda8c93ad", + "adminx/modules/PublicSite/PresentationModel.php": "3407e5e29165c264c65527433cb94d7d13a80f724dc4d1bf23a49cd2de88b125", + "adminx/modules/PublicSite/PresentationsController.php": "e2c7cc517f1a38d6b5e6828b1d652caa6b63622532d2f1f3c5f176e164ba6533", + "adminx/modules/PublicSite/assets/presentations.css": "fddb0b4300129f7d7c33853b7e31bce131eb6f867e577443429dcafac8cc67d4", + "adminx/modules/PublicSite/assets/presentations.js": "3a74e7c7ae3269b98eca52d2d61db1f4f21871c6f4a5425cc7bab58d24efd963", + "adminx/modules/PublicSite/assets/public-site.css": "e66be43cbe9df41e5fac5317a6a3ff405afcc6713c9777629cb05e76603f298e", + "adminx/modules/PublicSite/language/en/client.xml": "5a93fd6babc20670e54795211cf44ed155417aa933e5d4a50be21aebfa7bd510", + "adminx/modules/PublicSite/language/en/interface.xml": "e5e868af542e9fe6c981dff18e31a0050d2d7ae2b8b217e7b71bd98b496b5f5b", + "adminx/modules/PublicSite/language/ru/client.xml": "f49060544db65a516b2a643420871571b4da5911ae800075c01ae35e55a5d639", + "adminx/modules/PublicSite/language/ru/interface.xml": "67a7d1a651d8dd4ca3edc6c281d68c15146a8e84cb18833f9f4041fbbd57a619", + "adminx/modules/PublicSite/migrations/001_create_presentations.sql": "023d97f077af57107549dd9e420f5ed55a278fb8318caeb8d86f2dc7750dbfbb", + "adminx/modules/PublicSite/module.php": "a69322a39a6b658c24755a068efb42df73d89e41a11f8354cebf07f93aecb9a8", + "adminx/modules/PublicSite/view/_placement_pagination.twig": "b10413bdba07695500803625486b56ae5283c5a0a6dff83c668c8f93d1eab182", + "adminx/modules/PublicSite/view/_tabs.twig": "dc0a2f83f9f6a917357f4a327fffdc59bfeda83f69e2a0bf0b9805de93237b9d", + "adminx/modules/PublicSite/view/diagnostics.twig": "242682325bd7c99c75f9694d0e7ff6217124bf093364c027d13f609db660f4b7", + "adminx/modules/PublicSite/view/index.twig": "54ea9422b90235e2926924f11c43b56d90ca8e6c4f255501fcc9c52068cb6f92", + "adminx/modules/PublicSite/view/map.twig": "11fba1f54578b4908c669a7dae128949f53a7f1f03746f45a5059c92f473385d", + "adminx/modules/PublicSite/view/placements.twig": "c4c2af900bfae3ceb8d35802a277429a69afe47ddad49b6a6fd2509207dff36e", + "adminx/modules/PublicSite/view/presentations.twig": "d0b245362001ef2a1508fc5c4fa462895b4bbff104025544656828c4513d4cc1", "adminx/modules/Requests/Controller.php": "c6d2c56bf10f28cfdda15c0f6d75e134ab5029087954fc44857323462029f1bb", - "adminx/modules/Requests/Model.php": "5a4c038c97b643fd603d16244bf7cb5654bc1d91b72bfec18a533203ac9ee7f9", - "adminx/modules/Requests/assets/requests.css": "a0f0a671d1dd2e16eb2a26b36c4e5381437c3996563f00cc03bf447bfadb7355", + "adminx/modules/Requests/Model.php": "211e9f5d7b40d1d174c75d92673c6072f10d6ebb1ddeafaa9a109483b042c222", + "adminx/modules/Requests/assets/requests.css": "c62d55179c0413c398c3bc2743a3f7e66e0cb653c85a24469ea8940ec5e9a438", "adminx/modules/Requests/assets/requests.js": "980bc0c1c311a40e0c49facf0a2b0dc984a3b05c8291ffc6a66f7942762b74af", "adminx/modules/Requests/language/en/client.xml": "544406edaf2b568062e697f7264b5c5fbe3fc45c27e019615601e831612db0bc", "adminx/modules/Requests/language/en/interface.xml": "766190d3f850f6fc414a4646d786f79f394ee8f03524b3a2ae820fbe792d4602", @@ -402,12 +425,12 @@ "adminx/modules/Requests/migrations/011_order_tiebreaker.php": "533f3bd0cd40af166feb13dd08ad1a57dbc0a14e044cc10827ffcfc091ff178f", "adminx/modules/Requests/migrations/012_condition_value_sources.php": "c874ea024343e4d698cda7a138e13b23776a58f51706c2a16102ded1234b2aa2", "adminx/modules/Requests/migrations/013_sort_rules.php": "4c58c56d9766c16517bc2ceee1d6313b1728e564a332fe9a5cdf69a04d7afe70", - "adminx/modules/Requests/module.php": "d950a229b063efda170bec7995678eddd27cffe706c500f8b979b938afa044f9", + "adminx/modules/Requests/module.php": "c8fae975cbeec874dda310e5653c6887e34a7ce14bd33b03b72a664d68145709", "adminx/modules/Requests/view/edit.twig": "319e06df37e3d5d449d7a7c6b2078b2049ca230109f9b754de550b3c34aab282", "adminx/modules/Requests/view/index.twig": "164490c16e041b928e1ecf604915656399690055d1f7de4bc88caf5f5abd485d", "adminx/modules/Requests/view/native-audit.twig": "42120fd41aa4afa39560fea3f2625a4e78a61cb4f9ccc0c0388ca319d4f39c06", "adminx/modules/Rubrics/AdminView.php": "a866300dda84253046f88d19a4467ae1a545c8b1dcac7672d35be986bb38a180", - "adminx/modules/Rubrics/Controller.php": "02cd13f57d5d85647bbc79b93a283e273c32327428f787343ab149524269d516", + "adminx/modules/Rubrics/Controller.php": "3dcece2c975460c72c3da2e60a9481abc4a528b58e128fbaa52106c48215d336", "adminx/modules/Rubrics/FieldAdapter.php": "edc4b730499111917360670632573b8d28cb7c192f71e88e8c60c7c8c3e981d1", "adminx/modules/Rubrics/FieldAdminEditors.php": "d3bb0a8cd7e4b44db93b780ec95c49d3974bb3fc3167110a2f8ef044e921c348", "adminx/modules/Rubrics/FieldConditionImpact.php": "77600152ef7e6a98cbfcdff1bc638aa6b1a664048bc2b839188b56a1ebf0a735", @@ -415,21 +438,23 @@ "adminx/modules/Rubrics/FieldEditors/ChoiceFieldEditor.php": "bedce6236a9ca6d0cc6da23589313b9e6694a5b0f804ae85021691d3f8b4e57a", "adminx/modules/Rubrics/FieldEditors/FieldEditorInterface.php": "fbf290cf1d6304a07c5bb753782d4473682b0cce78c6301c743ef05e19b45fed", "adminx/modules/Rubrics/FieldEditors/ListFieldEditor.php": "bc1c7ead2ed6666f7861418ed2222166a44870edc5efb401226a5b9d80e549a2", - "adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php": "7aa8364d698b6581a191ced8dfe681dc210da499904ca392c5f306d1e1d03414", + "adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php": "4497d64ee4b50126c6003fd5e31997f1ba5e391e08a97c4fb2f07a108f3373af", "adminx/modules/Rubrics/FieldEditors/RelationFieldEditor.php": "9504549a3b81893ec8a114a286a960450bbd302cb2f8f9af97660c8332f91925", - "adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php": "fb4df66a47e820095d5be0856a77f2217a5b1a69640c89c9791ba984248effd7", + "adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php": "e961e7ab2bf3a16d6d9131513f11d2d4b9520ecee5572ae7e1b7ae8475d1cacb", "adminx/modules/Rubrics/FieldTypes.php": "c6cc64b0e1b1fb29b65275d337e5747b9f9f68ca46171e7e7f07129cd27b8409", - "adminx/modules/Rubrics/Model.php": "0e9c3a8b54fc862b46e4f86a71d899d97ac7caff3c75bb287418c1b63e2e0115", + "adminx/modules/Rubrics/Model.php": "ecbf5c3d8d91c3979866c8675754572f40947a72d262713809a01b5ba42e7982", "adminx/modules/Rubrics/RubricFieldPresets.php": "80109bd9f066f077ad19e48d7c7599e4dbf7b1d0c06c54db9fc921992bca2e77", "adminx/modules/Rubrics/RubricFieldSetLinks.php": "41cd27e5947680eb184dafba5fd95f707ccad0c20fab2ca948cf882ada0adefe", "adminx/modules/Rubrics/RubricRevisions.php": "084118e4b55b077244bacc7916674a26b3cbe86a08d2e12084dc05f2798b2090", "adminx/modules/Rubrics/RubricSchemaImpact.php": "e82f93175f031d5772d9d8fd71541748e505273d1622027615abb80e3a97bd5f", - "adminx/modules/Rubrics/assets/rubrics.css": "345ca91e59044d21756fe5831aee00bd0ca04c603ab32d5c6a28065eba4fa567", - "adminx/modules/Rubrics/assets/rubrics.js": "986e6adf8f3bfff0385a70c4889bf05d5af251538c48569b26889a29a500e287", - "adminx/modules/Rubrics/language/en/interface.xml": "c04706d2f8902cedb2e4c1f00e25c995c6e207831ef5295d6c90ef2c64a61468", - "adminx/modules/Rubrics/language/en/runtime.xml": "659663c346f6fa58996dc60b852ba048d04580b734d2f711af998bcfca2e855a", - "adminx/modules/Rubrics/language/ru/interface.xml": "72ae7e3db1cc884f61392c70db0c5bd6b0363d8c712c6ad1730fb50ba1cfb05c", - "adminx/modules/Rubrics/language/ru/runtime.xml": "8cf25cede84b47e321e0b2b586a34dbe8fb6b777c752f8e489bd0dfae0867b15", + "adminx/modules/Rubrics/assets/field-sets.css": "fe5253279caa882f9623b058c92ab461c476c080baf2399425e522a36b75336c", + "adminx/modules/Rubrics/assets/field-sets.js": "efbb011014f756dd59ac1357441d06c9b69b278a9d934f7cd9d2d1d7398cd313", + "adminx/modules/Rubrics/assets/rubrics.css": "fbf07a777744d18d3653c00ca2e1177f4fe685f2b07b00e3bdf251db5ea1273d", + "adminx/modules/Rubrics/assets/rubrics.js": "d23435b0c05cc15e3fb6a18e5e1e7cdeabb8d3595f5437a6ea95a4e066408113", + "adminx/modules/Rubrics/language/en/interface.xml": "e9f7ec72f954e35c81838e982ae58580ee0922b0a34b1885119db931366945ba", + "adminx/modules/Rubrics/language/en/runtime.xml": "4cad3d97c6a2695d038286cedc2c4509230c3bc9fa789dba2da9db89e8d9c93d", + "adminx/modules/Rubrics/language/ru/interface.xml": "24df4dc3e08f8ac35d261c269e618c4b613eac6585493c58fa180a945016223e", + "adminx/modules/Rubrics/language/ru/runtime.xml": "1bf3f8341af4561a4b6b1da98a28b689105285758da0fed959a6222fd83757d1", "adminx/modules/Rubrics/migrations/001_create_rubrics_tables.sql": "a827a92e9d83361569b7e1e7c65b65cb019d1675a9078e05002de25b3e62c72c", "adminx/modules/Rubrics/migrations/002_rubric_field_settings.sql": "4406f7ce1a9e9b0a105c4a33588791d41f12a55449baeca2565d5573e79f798d", "adminx/modules/Rubrics/migrations/003_native_public_document_context.sql": "eb440a23ced2794776fff47a55609d76489af324f14a084ca916a5ecf2ec5b4a", @@ -443,8 +468,12 @@ "adminx/modules/Rubrics/migrations/011_schema_revisions.sql": "d9a4d98640bc8b24b650009b90781f577b846e6d9f7309b7537ab5139dd61064", "adminx/modules/Rubrics/migrations/012_linked_field_sets.sql": "595229c2353f911beef5bb04d552080be5e1c8a36f440d9c06ed9400dd33ff10", "adminx/modules/Rubrics/migrations/013_group_form_conditions.php": "86544b9f2304b2c75917fe49b33da255c7677aedcbc9b76fbe13f9cd65568695", - "adminx/modules/Rubrics/module.php": "e9e65910b3366c767eb468d5a5b443c7de6b8ebddf7c6b9acdf303d5c98333b7", - "adminx/modules/Rubrics/view/index.twig": "4b6f220c811c9a1a524ad3f8a67ac5c206a5e8688d4c2053a76a48ec47015857", + "adminx/modules/Rubrics/migrations/014_rubric_purpose.php": "2913a1a7bb6f4c5665ad05704dcbbde78c7ab6b75b0ad7643fd00e482af69411", + "adminx/modules/Rubrics/migrations/015_merge_directory_permissions.php": "648b3e232e9426f84d69238295515342e42695e46288b901950cde2e8979b0e6", + "adminx/modules/Rubrics/module.php": "74392f60d8d43cf91fff4d49faa54f0792eece8949f11c51a5697c0329b6cc9b", + "adminx/modules/Rubrics/view/_content_tabs.twig": "e4c0a9b899b5085aeaec2e1793b8eeac4a7619959fffc7ce39151af1250e0d9f", + "adminx/modules/Rubrics/view/field-sets.twig": "911dd7cc9546411e1b29724466328c7d2db8db630b2b52d33e8443a066b16810", + "adminx/modules/Rubrics/view/index.twig": "4e87c3457cd6f26df601a7e13f701e16227a36034eadec40fec002f92ffc9ce6", "adminx/modules/Security/Controller.php": "c7f2263f460057191402dacf8948b822f9b33bbacbe94e6e9ad871613147fc86", "adminx/modules/Security/assets/security.css": "fd8c477d07c38450587153402380040b72fa9a92c60edd532c4b21046cfe375a", "adminx/modules/Security/assets/security.js": "f270dd0b910b9aa4815680b8d80d66ffb31bc7862f74e628cc60efba4205a99b", @@ -457,19 +486,19 @@ "adminx/modules/Security/migrations/001_create_ip_blocks.sql": "832aff733e2c25a43164c60a601dba08c21fa29ac9e826568055dcdb65417a76", "adminx/modules/Security/module.php": "557308dd9dbe114e5c7129e1cfbc06f25ded104e08c473513e2f388ba945198f", "adminx/modules/Security/view/index.twig": "b70bb298a3e1aed377d31bc6dd6b7937cbdcb71cd7098113b8b026ba9dbfab84", - "adminx/modules/Settings/Constants.php": "9cb232e8f0356fdf6f8f8d8f428486eddde281b58e486adbbcb276b88993faa7", + "adminx/modules/Settings/Constants.php": "03c0af9ffe9c17fe879974c49396567ac0584f2474101715baf6ada847ac3ff4", "adminx/modules/Settings/Controller.php": "90aff255d936753ab763ec534a097e9988f6c737bd5be2ba1194a7700a372e72", - "adminx/modules/Settings/Model.php": "93afb7c30aae1ccfe665b148d182e0d2992c043b0731c228689e9060a546a5a7", - "adminx/modules/Settings/ProductionDiagnostics.php": "a832245666ae4c00ae6969507686a9cb6db4f9152620ab1e21b67cf60d0ac902", + "adminx/modules/Settings/Model.php": "9e16004c78a03d39db1bdfd5e23b88b77e8afc7b8f57a2a21997602ace277440", + "adminx/modules/Settings/ProductionDiagnostics.php": "41430528b6387ad2df815a3834f3032c96e29a71d099ce68a846dbe942d4acc7", "adminx/modules/Settings/Schema.php": "7367374ace3e25fd86e5a829ebedb0358ba38bf5be0cfab9b859f1e5d3cf0525", - "adminx/modules/Settings/assets/settings.css": "91d075a30e57b07f9d9faaefa9d01f06092a6e4cd322b3e14c4881dbd29cb697", - "adminx/modules/Settings/assets/settings.js": "b2112d9338f82013c48b57926a9f9340dd8453aa354544a9de25036f5ff8a06e", + "adminx/modules/Settings/assets/settings.css": "c8207dc25cdad75f5d2b811a4f8b4444dc48df88d9a0982e9d442f810497bacf", + "adminx/modules/Settings/assets/settings.js": "4254d8985d73ff4646799eefdb54deae1f094c2373531cfc22d6692e48b9d1a6", "adminx/modules/Settings/language/en/client.xml": "28cd3896b4574e87d9968ca180bf02ca783181e11448068426f7f23503253acf", - "adminx/modules/Settings/language/en/interface.xml": "0472feff0bbe3588a2cf49e17fd0f775f9c263add6cf5628a6b4ec48d90c62d7", - "adminx/modules/Settings/language/en/runtime.xml": "9cf8627efb2fc5a2da3ec42fa0c2467134480ca96d921228a0e5b13b7de59749", + "adminx/modules/Settings/language/en/interface.xml": "34ed3455953a328ef0d57e5d62e7d8db552aea380f99016863e216bae582a06b", + "adminx/modules/Settings/language/en/runtime.xml": "b1e2613a192d781927379b7732a1c786e8ffe28965010f9be50b525784032ec6", "adminx/modules/Settings/language/ru/client.xml": "10031d39c8911a075a461acfca9825e20e9d75836f702e41fd864b7af14bf061", - "adminx/modules/Settings/language/ru/interface.xml": "0851c5623181ffdba24e8438c2609c7c0f32b06e9f06a693194b6a04b01622af", - "adminx/modules/Settings/language/ru/runtime.xml": "30f6f63cfbb6dcb4cca6343eca154e1ede24f53441b8dd0438770c3b80f57649", + "adminx/modules/Settings/language/ru/interface.xml": "a3ca37cc1dd1f5fc62eb247624225faf5aac4c36383f88a376d864a09031978e", + "adminx/modules/Settings/language/ru/runtime.xml": "2c8e5e4216239b0cc848b8c7561eec133db2d9e4218f305a43f4216d40c6b1b9", "adminx/modules/Settings/migrations/001_create_settings_tables.sql": "69d880693f1f4a68b5c917035841cf32de8015229504fbb7676992aa71c1e4fc", "adminx/modules/Settings/migrations/002_normalize_breadcrumb_separator.sql": "fc9af9771803b47ad3ddc21bc5ef827dffa6fb0325436132d9a63619716ce292", "adminx/modules/Settings/migrations/003_remove_unused_constants.sql": "20201881c41cd92bef70903deb0b6771d29756a7d60c22f734c57696fa6c7446", @@ -477,14 +506,14 @@ "adminx/modules/Settings/migrations/005_order_system_navigation.php": "df82c4966f28ff3b7d9cf184013477c60a4ae635345f21809bd58b94310e8957", "adminx/modules/Settings/migrations/006_create_admin_saved_views.sql": "69cf1cc085a31b2310f82fe55ad6e0abbb6f14707715205e5a53f06334759a99", "adminx/modules/Settings/module.php": "a754703ca23db15f500d3edaaa912abeb2097c32bf8615b31605f6018c952199", - "adminx/modules/Settings/view/index.twig": "c21736be16c030c12ca87efe15877419422dae8493c33605e2d1a753a65c1e3e", + "adminx/modules/Settings/view/index.twig": "7d64c286f8e580ae51b1529253fd816cdcabf8122a564fcc428ced98bd7b14d6", "adminx/modules/Templates/Controller.php": "00b1758860293b56b052a8c41f7174d0d97ba1d78e012b80f8e9f29194eb77d4", "adminx/modules/Templates/Model.php": "4f7a9082f462170533c0c140ef085f6a33111d20061b464864eb86066d50289f", - "adminx/modules/Templates/Revisions.php": "59ddb9b0e65ce12df10a6c1d370c5988ecfe14307852bc157731f7a13b16e0c8", + "adminx/modules/Templates/Revisions.php": "9ed6496fb8e28474028944cccf0f34af98f816374bcb54075c8222fdc9a8864c", "adminx/modules/Templates/Syntax.php": "a0172c03a4e091f5fcaafde39713189bc341c1cf7ac9ae1eed50c5cec657bd24", "adminx/modules/Templates/TagRegistry.php": "85344896cf21767c67b19092830e1ee9545b21067f1e43140efe2faf6eca2731", - "adminx/modules/Templates/assets/templates.css": "083100ac836b6b1cdf3f8e2d3ed9dac08bc4504dd6f8299562df24dd133a50d3", - "adminx/modules/Templates/assets/templates.js": "6684f9d2c1f3a956fb111035b5390dd62ca2afa79ca75cc0acde84a8715d1f81", + "adminx/modules/Templates/assets/templates.css": "fca6f4e7a5deffdc99d4fedff515a59b8e76c8289961971947e10ea384e9221d", + "adminx/modules/Templates/assets/templates.js": "5e5ba6534ee1752d7b23154d44626358a83ba209461b3c0623b8f07efc20fbd6", "adminx/modules/Templates/language/en/client.xml": "d7280440a3c00674ae2db6a2c837a8114fbfd4fa27590b7e0ddd72395fb08887", "adminx/modules/Templates/language/en/interface.xml": "50d541fc8b9af282413030476e7677ff2fb07ae20168311aa130d37e2b9670f7", "adminx/modules/Templates/language/en/runtime.xml": "4d9c9214729aadcacb7e062417b8befb2965f3184ca39334e2aef7fd90fb47b5", @@ -493,12 +522,12 @@ "adminx/modules/Templates/language/ru/runtime.xml": "800d784c94fcee282dc396f38ffeed40595de7adae019ff960f206a7baf23731", "adminx/modules/Templates/migrations/001_create_templates_tables.sql": "eab8a337e8bd6b21936bfe908d20bb2d21e18e33ceef9df7245c4c0a4bb14259", "adminx/modules/Templates/migrations/002_create_template_revisions.sql": "d7d05e99795ef1d376b16c55f46bf45cabccc76e11695dd95eecfd15e845b542", - "adminx/modules/Templates/module.php": "0c5740a2494ce0335e5b94a63ed509921e2c3f40e32981a03996dbc9f814ba4f", + "adminx/modules/Templates/module.php": "27bc0cac9d0e5ad9c4c84d041a6d19f2c0d3d870c90acc1b3f806ba119d7cf21", "adminx/modules/Templates/view/index.twig": "7c53cd26dd6bd6fd4af757ed53e239c0ba21578c75aa6cbc575ffb5d0c6db454", "adminx/modules/Themes/Controller.php": "85af532e079d799c0bad87921b99587c946fe92ec073b4016eea650e5c4ef12d", "adminx/modules/Themes/Model.php": "e291c6d4bbd964e7086180e7329df2867e2748c8f007596a95c88a954159e320", "adminx/modules/Themes/Revisions.php": "04ee9814b4f0f329ad9c1983333f5f923343bd0179d68897e21102dfea97c9df", - "adminx/modules/Themes/assets/themes.css": "a7a7e9faf7dd415451ed1e4d1e71da214f33b8b4802259c1654314fda1b79ee1", + "adminx/modules/Themes/assets/themes.css": "e4346dcd646702a204a59339b9d6d8ace4be59337343a4b12d72c857a780103c", "adminx/modules/Themes/assets/themes.js": "6c1b2346f475f97299b8657ca3d867293680c40df929ca467c4c30dc81d76c0e", "adminx/modules/Themes/language/en/client.xml": "5f9d0a1ca7320e8590468e626433fff92b55cb9ca38d8723d4a363feb77dc3b5", "adminx/modules/Themes/language/en/interface.xml": "30365f6c02c048fa2d0e94feb82bda45bdf8cc9fa665a7943593bb60636f368a", @@ -508,10 +537,10 @@ "adminx/modules/Themes/language/ru/runtime.xml": "f8046256e75de65f6f20c8d5b5bd6a927653ca4dc8b6b8f205756b255b624c51", "adminx/modules/Themes/migrations/001_create_theme_asset_revisions.sql": "21a2ce27d0b97be6493cdb86bffbfb0cb8b16762cc7b0025d83bec71d56a760c", "adminx/modules/Themes/migrations/002_place_theme_navigation.php": "62a2f2953f3d5bb8df7e39c83a1de83a56008952540c8c28c6a12f33a8fd7f95", - "adminx/modules/Themes/module.php": "2f4ca527a426bd8973ff2f3070404c350ad8e860094544d071f807e46b2e56cf", + "adminx/modules/Themes/module.php": "d5fce96f5841882714e8f540369568bc3c1676313d1a2ad659b3efa96c71189c", "adminx/modules/Themes/view/index.twig": "68a06ac9ee43b0052c877e89c411a1d149f212418def78e44a1f4a73d80344a9", "adminx/modules/Updates/Controller.php": "1aef3e6cec043e4e66ab862b4f0c7081d452bc8a1295fc125860eb0b02fa89ce", - "adminx/modules/Updates/assets/updates.css": "bbd75e2ae0b6b1fc66a69b13cfd6a7244fc02dbf90895e33bdcd1340b0b0b815", + "adminx/modules/Updates/assets/updates.css": "53cc7af1afac5910ffd0c11be847215fa53ff95b9a1efb140e5a5f3f6c48d9ec", "adminx/modules/Updates/assets/updates.js": "c3788da4b31718e297fefcc47e740f20be10342f3c637aa0a7c4678ab1bcd920", "adminx/modules/Updates/language/en/client.xml": "d1f09120fed6c918cbbd4df166627bc44579d530f61c77c753cf147582ce3399", "adminx/modules/Updates/language/en/interface.xml": "a9ac07a22f1e021eef53690adb1396efbf6f2b2c2f75948f48cd31e77c2acfd0", @@ -521,34 +550,36 @@ "adminx/modules/Updates/language/ru/runtime.xml": "45d622ea8c6f772d5f53690bd7c08beb55efcbd51c7308f0336c995885b68258", "adminx/modules/Updates/module.php": "c26612bb4383e4433543a9e822bc18360b57815af67875b29434bfdc8e263dd2", "adminx/modules/Updates/view/index.twig": "8d975051ca96fd99902714bd60b6a85131d6304578ea65a3dbf732523b638ece", - "adminx/modules/Users/Controller.php": "a2c3808077662612bfafbb80430a0a7a028e80d6d9300c1c7079067b5993d5ac", - "adminx/modules/Users/Model.php": "0cfff6474cfef89cbac69e9f4804c9347eec23eb7435a3fb45f7b033c3c6f6c8", - "adminx/modules/Users/assets/users.css": "98853d9eb68c8d4b99250d456185a30cbb41437254ab5f35113fdf4aa850ccd6", + "adminx/modules/Users/Controller.php": "ed5728b5794d250fcbec2e2bc83bf7812a4279e028949b2373ed83f7873d72d3", + "adminx/modules/Users/Model.php": "9937eca84e631dbb3e256b7ab100d6120ddc853e66a065218377a33b303f2241", + "adminx/modules/Users/assets/users.css": "19b53f5a235b79b5ff34576692ace47cab69e85586958f763118f59a07b2920d", "adminx/modules/Users/assets/users.js": "a9d7bd3b5b2e44988ac1c5d270d44f896b7cf1796608961caa0d8129508d11bb", "adminx/modules/Users/language/en/client.xml": "d1d17febaf2fbc74243f0e799bae71b32c656ceed81da7fc62f13dbf73ca7b4e", - "adminx/modules/Users/language/en/interface.xml": "ae023172c71b0519bb2aabdcf8a7a162ec4c45585182a3da653916e95c450fca", + "adminx/modules/Users/language/en/interface.xml": "28db6106985d83555542a171a8a59b06cfc0797251ad9a533fd4796be5eb4c56", "adminx/modules/Users/language/en/runtime.xml": "b68f556143021c97fd369813ee0fcdfe66af5febcc74ed757b53605b2b797b10", "adminx/modules/Users/language/ru/client.xml": "69d389f961f82692717c4bfdb6057700c23f09e226410108053ec9f1574295f9", - "adminx/modules/Users/language/ru/interface.xml": "ceb48f73f3e11c1bfb8ad0aa5716012c6331feefe9bad3809afba8f28a64785a", + "adminx/modules/Users/language/ru/interface.xml": "3129fdc122cf197452954b328192ef8a042b085b540f572de86fc5b239e7e462", "adminx/modules/Users/language/ru/runtime.xml": "00c74dd13ee0fb0a578f0465ebd74a9c2b87d43399ee0e22ddbc911df2043b6c", "adminx/modules/Users/migrations/001_normalize_public_session_activity.sql": "e72f1cacea2cff5f74f7baebe3cb1f154b87fd7368541b1a3a5d2300a0deae86", "adminx/modules/Users/migrations/002_harden_public_remember_tokens.php": "85c92acd3ed962dfc4bf85b78e07de87569b1f0aacb2c616287c95bb39fbee2a", - "adminx/modules/Users/module.php": "9df770b412405038bc0539722f369b801cb9b6d7ca9ea1e60218e11b101447a3", - "adminx/modules/Users/view/list.twig": "ae771b2dcd7f374ad6c2a3fbdbbb115a0ba0f4ee382ce5ca43990e911ae5411a", + "adminx/modules/Users/module.php": "81c13d81fd73878aaa372590b0ed5f2a8c19ec59d409c5199a304271b1011746", + "adminx/modules/Users/view/list.twig": "231abe6ddb6324a7573f2da75e1a75e29dd7dfbd4ed010a3b38a5cfa313d8ae2", "adminx/view/404.twig": "86f93ca1cb44b0c225a03a33704b903f8a96e1697a05d40b0b71dedb8f665769", "adminx/view/_pagination.twig": "9d5e761594ae97d7cb7b518592028adfd2041b5d99f736b24a318b2766e350dd", "adminx/view/_saved_views.twig": "51217212a3b0ef7437de4dd55a07fdf55aaa3138155b00f65fd85af686412da1", "adminx/view/auth.twig": "e44eb6337248470418be7015886e6e5d7bf5b0021fc917f4cc0dcd98c6492d85", "adminx/view/error.twig": "1187e6d0c325e0bbf6a7bd9836a5ef6d96cb8f72a0599050d3da7120683a6202", - "adminx/view/main.twig": "2ec8f2ffd5d6d3f2fd45027ab30cc9c8a611b0dd4e0c5baa081fce8021a3fb43", + "adminx/view/main.twig": "4aa00f5a2c50e3cc4d339c0ad9377526db6e8a65575843be59dfd20df5210344", "configs/db.config.example.php": "e42bdb47aa7d1bc9cd85cb36e05acf940cafd951d6220664c485d1b971f18809", "configs/index.php": "48e6b249e3d83530187c095ede0198af498c59f384bd458db74f9e0c2306eba9", "configs/public.config.php": "7047f7acfb9f64e56c7320c3cdc306522a187f5f9e12cef6e95937446ee07441", "help/README.md": "d12965e7851dec5241dd74db731c110a7e0f4acfd7139ae28ce21d53193b1123", - "help/administration/README.md": "a6658aa4a593abcb9f6df35edc4781529a518438bffb8fca77ba5502a161d294", + "help/administration/README.md": "b07c99046a52b6b30cf8e50ee00a33137087771ca486b8c4e365a8844096850b", "help/administration/console.md": "5efb2adbee857eaf0b8c2816ef1556addb2c9b21420bf972c6bdf62911fa9ad4", + "help/administration/content-and-system-tools.md": "55747a150dcf00cb3682d30b1edcf054201eb051d0e25274513ad41716a9f392", "help/administration/dashboard.md": "98a93834bb648b4b94fead19d31c371d988013028ece38caf9d4b8d455628e93", "help/administration/operations.md": "4c585d6c3236f11656000332ff99c066187eb36cd860852c7fc857020487c05a", + "help/administration/search-and-activity.md": "f0f470ab432ff344b176699393574acc2c9a9d153b2d71986db9201bea374482", "help/administration/settings.md": "89ff256f830a827929847d0d30662e1af0a2cefcf7bff214d6875d7c14a164da", "help/administration/updates.md": "1fe89c7ab6a1cac896bcbf447e130acc3792c7c93b3d2b063160dda32e1b9ca8", "help/administration/users.md": "4f035c98f136e7a3b794d3634c2d22809bfb07b3be3f8ddf3d403ae07d96c14e", @@ -556,19 +587,23 @@ "help/api/authentication.md": "71f289056bbcd688c2ed19c1af43e4b233db92d76760b95ac9e63efddea99c08", "help/api/documents.md": "717101e0fedee8ddd987d499a9ceffb19b4d24e0a04481aef72cc6330e88fa43", "help/api/errors.md": "630270fb28d5d385e368f3b0cc1c5cc2acb3c56607bb8c9af62c2e62a6d09993", - "help/content/README.md": "b52c0cfe8e5d6c114a33eb2c6d2452a0a36b5335e4f76061375d0aed4712bb17", + "help/content/README.md": "3278c036ddadf187cf1fb0648682f6a1acefdf5d1d95c4960692a35fefe8cf99", "help/content/blocks.md": "1d55be2a9f1a48df426e5856a54307d77b352d0037d68f08ed7e16a322d9ef8c", - "help/content/catalog.md": "8f40be9562c8031ba035043d16f744cf625c5f761c7896a4180e1e751c8e0a81", - "help/content/content-studio.md": "5d200853c3601be6ef8b4c82fe98d1bb7e82b9e28e562fe4857d06f6aa226c81", + "help/content/catalog.md": "5ccfe015ac45bed261bdae480a87704142b41c772c7bb8c425363a1dc06fc6d9", + "help/content/content-model-tools.md": "baaf738aec454b1855ea110b8f5e1ef3ceaaad51cb920d4551cd088daeab7e76", + "help/content/content-studio.md": "91ef485e614a9a71a07d4e6c143377f7052ddf3b00d3f2f0d0a32258bdb14dbe", + "help/content/directories.md": "ff6c5ea9bd7fe1fb9d0aa1875d03fc3bc00b2bd2b0ec83d6496cd55168db294c", "help/content/documents.md": "e5d7f51af5488cf3363326697bc5dec56f377b45963fcd6a029a354871661afd", "help/content/navigation.md": "a02d0f88b5a3aa67fd14a2c26a48c9c3c4bb73f4824fa453c20f3e2ef4ef24b8", - "help/content/requests.md": "6487e6c6a41b2cda89912dc535b279a1562bd6514b8f0a48037616fb32bfc1e3", - "help/content/rubrics.md": "556805ddb3216f0b3659337b86c03d72274962ebab0bda082f4b8d31c54661e4", + "help/content/presentations.md": "6e349bb4d1d1577617e6bf3d987b86ca6b255f64a47cd1ddb0e38c5242b788cb", + "help/content/public-site.md": "3abb9bc9c78059d22e8a75cc4d01220c0b9558da4a1b2c8b342bc26c8e353d04", + "help/content/requests.md": "c008b1026520c85400fb9240cb117fcaf384531c06e859c1e36ccbb3a79a8590", + "help/content/rubrics.md": "2c9842ce5647fc85663b02cef14f141c673694f0a20856c9262b6ccbea436c3c", "help/content/templates.md": "c6fae2ef91c741479d1f34fe66d00e83f2d2ec1aba1a4099773a04a6e1b6552c", - "help/content/themes.md": "439b9dceb6b49a66c05c94804d06a37d8abd18ed4c26660c3234c6bc35a2a620", - "help/content/twig-components.md": "cc977a81090acd7cecf0f4550914eda716701f0b93eb5988f92b7738964bb166", + "help/content/themes.md": "f522107e2ee27590a40d726f26538a3101925d0d0173fa1e58422089b5418388", + "help/content/twig-components.md": "15e40ce305f4416eff6ede5168be32c033f712776484fb6c72fead9a0d7d70e1", "help/core/README.md": "c8c42e4915e049e2821cbb548a496bd4caaa9c7921a205db5d7ddaa9445ace42", - "help/core/auth.md": "acc76eb4b00f07a6d54b6482e8f18c2aaedd304290acc6bfa2d73aad130a739e", + "help/core/auth.md": "da714664fb7848676229a9695d49c811786165a79de7fda4b7ff0156a52eff6a", "help/core/bootstrap.md": "72b6759b1066e2906dc855dac13880e0227c1d70f751f81365c6afae2cf919f7", "help/core/controllers.md": "6767a22647bd06b7af5bfee2a9268bce95d67baaf3f4a828b88233def1e4c665", "help/core/permissions.md": "3147cfd90fd1139961822505e611c444e54bea4ab66b86f1c0da121301fcc57a", @@ -586,10 +621,11 @@ "help/debug/README.md": "41b7954c443330efe28cd3a14519c7c8076165a9d96c0dc3d3781a111e757085", "help/debug/custom-data.md": "445ed27dbda5b0ca035504e2f0cda023746def5edd3aa1f62189e133946e293b", "help/en/README.md": "cdf84634ac731f1f434ec496b171307c38fccd6e6df335dd4dbf074e3e2cc12e", - "help/en/administration/README.md": "fadfb46c1f45d3d7afbbd6e323221b099fb9b338cf3c17bcb8826b26a67b9372", + "help/en/administration/README.md": "5482951c74022a90b44f70bf8c47ed4499b95eeabd79bd99edf33f89cfa06805", "help/en/administration/console.md": "0938f6598c01381d79235a6b524d34f0f9a35fa37a35e7042baf04cf4f4c195e", "help/en/administration/dashboard.md": "1945cb5191944f41a88e5a59eb91f1baef550f9a1d751817b08be5112d23d122", "help/en/administration/operations.md": "cea8833c931040aa0d00ba0a181270cf95b6ca95f199b6c250c0c8b839b86b97", + "help/en/administration/search-and-activity.md": "65f3df81100e462eb16f17a4d07c8106156a99166cc4bfe18fea2e078318a669", "help/en/administration/settings.md": "68586a8a8a397d9b5f76d52867f5a1ea0247ee47087faec22802b2f8c4c2c755", "help/en/administration/updates.md": "a0ec3d0929602c59935836b5e03e35679b86058bc482dcb37475f892262401be", "help/en/administration/users.md": "018541f3fae298feb40f0c162bf813b2ef0ee4e782c7067dc5c4efb207177db4", @@ -597,19 +633,23 @@ "help/en/api/authentication.md": "23ef6699d3082373dfff77253a79ac5179c0088d3f69e1d1145c8cc7e05efa25", "help/en/api/documents.md": "266d2cf298bfffd62a02289b97bc01be508d6dcb71d0a8b0b3bf840afa5307c1", "help/en/api/errors.md": "4c8ca63333fa082aa56cff33c1ad3659bc8030524e490a2f43d122ac4ef68309", - "help/en/content/README.md": "4da5eeeca08496e4f4d2fd8634da5ff021c24744b70b6a16d646dc0a8a644b9e", + "help/en/content/README.md": "b88e329102be722abbf1b8a9e99d90ad960e79ec73fe03b1854276c92d5fc094", "help/en/content/blocks.md": "34657f0a7ab977bf78d5b7645bf80a0755f40830bab4d4620ca7f39ea5992210", - "help/en/content/catalog.md": "b22e818f4fc9f7d683df31c6357dd97a3eca9834b8c5cb1e9b0d80f240280668", - "help/en/content/content-studio.md": "746a9f538d951f7f53de0e80db9f08e3bac0351b39ad286177b1ebcb04d3a76b", + "help/en/content/catalog.md": "d4139c69df31e32b4c37308f96d539020d989f952f75b9f601565303cf2aee48", + "help/en/content/content-model-tools.md": "fc50f8c334a129d3ead4a58503a7659abc2b1fdea7c9d277feff1e3ba4bb7b6b", + "help/en/content/content-studio.md": "194fe41a1eefdf7da3c9a47b8539ed3ed9d785d2a75816a39da6d2784ec40925", + "help/en/content/directories.md": "cfadf2361ead3bed9b63435041af8c91e24ec8b492906f0d9b976047edc123fb", "help/en/content/documents.md": "369712175c276d241ab5545ff35c2df56e62d0445c4506ff6b7610d9def035b0", "help/en/content/navigation.md": "18f6e033c5c01eecbd7a3bc4b7c557f272f0039c811ea6f4ec2354206b0a5f21", - "help/en/content/requests.md": "152b82072cc9276482bf6921a607b7a5564d211644d3ebba66083c8554fd2845", - "help/en/content/rubrics.md": "e933018a487059b7d1a867c08c1f34fa2f3a40a68dec60b1bbfb492cc9d5308c", + "help/en/content/presentations.md": "f255470ae47453d2e458b1829316c0e1e8e7fae73c9a1c1190d89f522b78d871", + "help/en/content/public-site.md": "ab639977029d10f8a88679b524c771ad6437c49e4e061b8f95a4ade0188efcc4", + "help/en/content/requests.md": "71aea0d1fa6dc9b6f6dd41f1c7a4d80af93c0b5e18341dc26f59d1f0c156604b", + "help/en/content/rubrics.md": "bd69370ac19ab0aaf6d4c1cba615ae21f57cc3e241ecb6706f23a69a794fb3c0", "help/en/content/templates.md": "f91c141dfee841a1af8321625788cecb9dabaef75901e188127815b6d5b5ce6b", - "help/en/content/themes.md": "e9d0d80383c02840480410224b84cb14b097e3b30124b47f600e2c6c569142ac", - "help/en/content/twig-components.md": "c1e100c88678c044245f213b734efba232fe30b623ba8ccd34be9399d3098809", + "help/en/content/themes.md": "b502abddb9114cc6cfb990e75c34fbf970c4e9c95940f27bddbd5fec80747b0c", + "help/en/content/twig-components.md": "5fee2a73c795f96168ae8bcccbc4dabcad3c82a0adce69a2e2add878dafa033a", "help/en/core/README.md": "ce0c4d0757cec5786cd79fdeb8a839250a947d87e2b5a5905f7a8010db78df6e", - "help/en/core/auth.md": "235ae7b48e66e6c8893650d5a2225e12a02e24871977099eaa41acff7d74192a", + "help/en/core/auth.md": "1fc1e444627669e1c2d0b5b15e363bd69dbfd18085783695229015213a9a0cc5", "help/en/core/bootstrap.md": "e2c7b022ccf6bf9dd2e888d3cde42bea82d36be382d8d0cbd508d3facee43f7b", "help/en/core/controllers.md": "968107531f9bd1a714a62d9f390704f29136fb531f1b72a36eaa72cb0cb76952", "help/en/core/permissions.md": "6e6f1d5d1c20c85f6e3d71aa3f4a7de8e822ca08358aae38981bc7f7082b13ec", @@ -629,7 +669,7 @@ "help/en/fields/README.md": "46c4fb2b44f1fef2f25ed2be3c730dd1d4e1aa0f48950e6069c3772acdcc5b17", "help/en/fields/development.md": "4a953779ec8f08a79e235bd658d58ebc69f5758e2d8c55d49d5bc074e5aa0a41", "help/en/fields/operations.md": "7f3e2e991df10e5bc8bee9bc7251b9bd96e5137aaffc5142401f80afadd752aa", - "help/en/fields/settings.md": "a3556e6bea0982f91153926b0096518f299f341935f188fad4a3934f651b2f2c", + "help/en/fields/settings.md": "577d87c57c76fd959e8eeb6a9e25060ff82d43846eecc5a02f95a700872743d5", "help/en/fields/storage.md": "2e8d0faf13e79f508e6f91e78c3b988811d3c39379c1fa79de441e06a44ff64d", "help/en/fields/templates.md": "634dfc6e4f14b3beb5c84cdfaf5d2371535c51dd464638474ae5b67a4dba0f8a", "help/en/fields/types.md": "db8451912c4910fe0bc416cdfcf23f16a8978dbfe83f481f887e2fd3f3832bb0", @@ -661,24 +701,25 @@ "help/en/helpers/Valid.md": "739d5da3989b3d49bf958f670e5e3670dad143058433b616da0f17af276e09ff", "help/en/helpers/Zip.md": "e77e4a66c0c4373b25d665d7e95eae1d5a0af4bb1589f5efb8985622231ba13a", "help/en/hooks/README.md": "92ee41f7b9b9daba4c1bde7099bfd221dd3499efde9d8f3f9b56ffdbad0cfd9a", - "help/en/hooks/catalog.md": "4541de3babb6f5a6dcdd8c495bdb0adfd6ab6036ed5bbbbb4bb3e55e06ab9b12", + "help/en/hooks/catalog.md": "44f5d6d1a8d197a5cf2de8e3ce7084533b3919a27151b7d05fd5beeb0e085999", "help/en/hooks/debugging.md": "910e07cbe4a802efdc7ce439eb9eb0e6231d667ee527664711bcc8e863e0bb34", - "help/en/hooks/documents.md": "94d713a991561c8fb7642c4bea1efc491d9255aa3c6c867006ebfb89cb270e4f", + "help/en/hooks/documents.md": "381f0d983cc2367abb99a2604960f2443ab17234344350fed22b2064b4629067", "help/en/hooks/fields.md": "4fa1dce004be2b1cac106fcde0959908880d2e8992be9df0e5b981aa166672c6", "help/en/hooks/lifecycle.md": "ec82cd9ccb2bc48e9054ec1876c40552859da4ead45129f920026f821f84e87f", "help/en/installation/README.md": "a122a89fc4a910064f02fc906a0fdf3a9098147f9ed2f98a75185ed66d190f29", "help/en/installation/admin-directory.md": "26a7b2198078f8ea11dba7fa6a0699cf35c66e78fc88279c82012e6a01f59a38", "help/en/media/README.md": "cb9abeaed976e18cd7b294f7c078b7cfbda339e0022853d3656c7a29b7fed13e", - "help/en/modules/README.md": "103e48b81c47f8b545df1563a0bb6726aed73d1f2d3c7b9f5960121ea61522eb", + "help/en/modules/README.md": "52a6ba0fc3d163c4912b28021b272977f47a3f0120a906f8e8c614f143950a47", "help/en/modules/antispam.md": "1808f910d530f7068e60e2a9306c15ae5b69cccc30ee3e492f35cd535004f65d", "help/en/modules/banners.md": "b6046d0094b0554c31b56dd15ea299e247f5f3dc13c8ca8fce5714bb6c3193cd", "help/en/modules/benchmark.md": "e7b9d233abd386db41413f45c79639a3bebfc37241bdbab461028e87a5615f87", + "help/en/modules/cache-warmup.md": "9404771e435f1130fa6812ba7c444e21933f1e47487ba7f1e96e8992db1d224a", "help/en/modules/comments.md": "854896934b2d93d6527f9ede94e85b5e9aecbc282fdaeb9572f810c7dd773dba", - "help/en/modules/commerce.md": "d3ce1e24e4d719adc9e1ada79dcc562205f7833d88b9944b5113c9abf7ce79cd", - "help/en/modules/commerceml.md": "250c24aa6b88a814930960e79350d88d78407e56fc564677430ab7e216befae7", + "help/en/modules/commerce.md": "38ae79ce7675efae05a55c9425a1f61ad966556cc8cbccf3355087d0a9d6e96b", + "help/en/modules/commerceml.md": "610f6b2f9abbeeac604da0408192ae52dae7d6f3fb09e0d321e14d1cc190b808", "help/en/modules/contacts.md": "3d4d7b109a7a7bf20ceda0cb0da65634131a837c7d39c4a1433f317c3bad3512", "help/en/modules/content-packages.md": "4b89df6d05fa75e484e38e404d53fa7ecc6b61aa6930fbf74cf9b6611bfbf532", - "help/en/modules/contributions.md": "ea01a8cdd61e5df55e45353ae1c05804095646b2d33e70704fc6abc8722f294f", + "help/en/modules/contributions.md": "ec283e61f09d6008a7208fced495a2748685c50bbfe22b384f458cf338e63125", "help/en/modules/dashboard-widgets.md": "363ebd63b5d28c23f4be2e3cded9e690db78cb0b79c459217eced1151e2b6a8f", "help/en/modules/demo-site.md": "d8ccb9d14f664b905c89d81fd046116d1b6fb45f2c0105ff2876d5c19b16f57e", "help/en/modules/document-import.md": "cf0726b5cf95b6549ae6732ea37ce127ef3a1fe3b70109d83d74f5f48e4cf0f7", @@ -690,8 +731,9 @@ "help/en/modules/header-actions.md": "d5126af7516e5fbc71eb8bc18b20fe00b20e25b987c285df0119578dc7a8628a", "help/en/modules/help-viewer.md": "50411de1d2069be54fdb6aac44285bf860f1bff61b903a2f4328d82b747598d7", "help/en/modules/interactions.md": "3cb36ab97d91ab3f2ea1400259cbcd5072fc140a9c2b0295583feadad566c7af", - "help/en/modules/legacy-migration.md": "a137a6be5043c0b6c85de052608c624a5ae5b33202638b01d8e733346eee5625", + "help/en/modules/legacy-migration.md": "f9dabcd0ca6d69999ef989457a98b6a52ad29b42fc9958079e4b3f203b1bc15e", "help/en/modules/localization.md": "f84bd43534cb3147f5675908deed2503271e9a96ef1cacca8b3fab17d8917f6c", + "help/en/modules/mcp.md": "98afdfe8128282672e6915159e39f6b1337b295022d9091c392c5a76d41ae239", "help/en/modules/migrations.md": "5a2d09f1252ad7c4670d06db1a1dd029593856fabd219005bfc0d9891bd6c2f2", "help/en/modules/module-php.md": "d61b3a47097623c1d969b716d78d08939f446980648b0718c1dcef45b7af8f36", "help/en/modules/not-found.md": "77ab48c7428a8f053938a780986d9c2d435be5c7aebc1d70093574776436c691", @@ -703,13 +745,14 @@ "help/en/modules/polls.md": "a8f86b04024cfcf490e9ee2f5dc2e4293ec088cdcc48dce11c074b02310c1180", "help/en/modules/popups.md": "46a2d6171349d186cfd18af984c7a65dc1184f62406a700767233f3176e789ea", "help/en/modules/ratings.md": "37fdf968a33dab9224ede4f71bf093a8d9121613574961341fc0e436a2bd321a", - "help/en/modules/related.md": "1bacc84e67364da53fba3a215c2b2337aa86f7bb75174b20a8bd6576d54d4ccf", + "help/en/modules/related.md": "863982d9102efb874dbd362107bee773780b7c319a78b544a79008dd74fc64a3", + "help/en/modules/reliable-events.md": "7069a39f950f0e5688b09633c840e3be73b0b4c244b89115016e357aeac393c8", "help/en/modules/repository.md": "1f835de827a64b04d6c00a18c060fbd6c414a7367daf8f10e9b6c6106dcaacb2", "help/en/modules/reviews.md": "0175bd201536c306b2583858a248740bf0445cb39b5ffe37dc76a9f6a306c0bd", "help/en/modules/rss.md": "22958cb500ddfcd83201086b36e43365d51f9f125b8663ec38e2e30eded4d0ae", - "help/en/modules/scheduler.md": "461c67e31e392c90bf60a6e0d9d55c65719a15183cc5454916305b9c4d0af4bd", + "help/en/modules/scheduler.md": "fc0c34af054e0081c6ad8cb1abddcff014c0a6eb77178c111fb0b15e0585c75f", "help/en/modules/search-analytics.md": "3e21c935d76171a5a74cba9fc2904092d2d30062918c2f7ff1988aae0e499264", - "help/en/modules/search.md": "a3e323119de8f3783c758db745ff28483feeb93685cd8e998771ccc119e18212", + "help/en/modules/search.md": "973ff0a67f7ccece663d74c2a92c177869ac3fdd15484aa8c2d602c4fc5b039b", "help/en/modules/seo-audit.md": "e18adcb79847fb5a1cedc8cfff03b5938eac1c2e2d22816ecc5e5b02b48b95d3", "help/en/modules/site-readiness.md": "dbccccf0633f0e72d32cc8a2b0de319dea576f28b67f0d9c2907106c97fef1c1", "help/en/modules/smsc-auth.md": "789c352d0d9efa5d1c4d88f88b508d925b08518704326613ce35cadd1bb87fb1", @@ -720,7 +763,7 @@ "help/fields/README.md": "01d359b4ebeac9401be857443c4c16aee73933cd7d6307e0a25c2e3db6f98d5a", "help/fields/development.md": "4b3de5adc867d34dab7d3a658eefd4deb0622b9c62f1a4fa5a6b0f24ecb264cc", "help/fields/operations.md": "658f5ba5ff96cc99192d5486275d2ce9f98555cd8c8d43aed9f8f08a14bf5ce1", - "help/fields/settings.md": "e1216af1489ead6ae4a1fad514aab68afbefdfcef548be11759ef947410547b9", + "help/fields/settings.md": "cba0acfc0fd93818e2e13c100026bb46643d4e579964ba9ab6d50fe7675599aa", "help/fields/storage.md": "502a8b0ab9c22b5e023f71f94cd0960bf00506c9cc2368ad72f54c354595c116", "help/fields/templates.md": "7538e7df6ee2e7535a106c97c6a3d980678a0f148132d530bfcada9fcad4be7b", "help/fields/types.md": "3197b168942ad3bdad5bf645cdaf4d54765f52307b6947041a7a255f5225bb60", @@ -752,28 +795,29 @@ "help/helpers/Valid.md": "384c0c880fafdda2c69dcc6340f09e6e4481c7beb04fb600e568e7b3e3dc6e8a", "help/helpers/Zip.md": "48966186d2812305d3bb74058e53c454085f52b6422e2a3189768e77531faaf2", "help/hooks/README.md": "562b3d8c96ffa407fddbb327d51b68d7a60e8be494bd9364a7c3ce14807c5f20", - "help/hooks/catalog.md": "cc1043d2827f52ced69a559a4e52b6c297365ea8075ee4594ee211edade5833d", + "help/hooks/catalog.md": "9c42dfcfc87713e5df9f1214706b4ca82cede4164bf8301c249ac83127877c7b", "help/hooks/debugging.md": "643ccb475d890220598b6d07387dd2c1a65735e7768c9616a26eaab7a1862407", - "help/hooks/documents.md": "6380c1674a13ac0d3caeecaca1e3f5019b43dd44e868eb271906d6fe2c0bec07", + "help/hooks/documents.md": "a43dd353016067e43083d87f6051363204e71d406d7e71070c58c7b417e7d9fa", "help/hooks/fields.md": "62737f4bfcced38ca8089267fccccd2a5769551f79cd884b496d40f04e9ffa87", "help/hooks/lifecycle.md": "0d73b7eea62dbc1cc5f3a07449f465105ad8a477adf85b22d2adbc53529b81d2", "help/installation/README.md": "6a9c434bbe81f493ad3b07ad83fd8c8a8739423f67f3e657b5a9a3dba0d2738a", "help/installation/admin-directory.md": "fcb31d8bad1c18dd7e5c6843dc4f7b8c4139dbceaa670a7c4e0c060479f5ef9d", "help/media/README.md": "2693af05de4dc54b280427607dfa5824e3098944b93ba9b92f76715060b9864d", - "help/modules/README.md": "b901471d9439915bcefa728957aa20ad351e3fe5753f8cc62b43013e0cbbd753", + "help/modules/README.md": "a232a6e666b9ceea49a140b1a9d896cdf9e777581c6dc6ce287967e97c0bdbcb", "help/modules/antispam.md": "ddcb858c6c80ee140addbdcd0e27ae3f7900100f1745ee006edabf52d0955fff", "help/modules/banners.md": "e8faa2a923c5f5c39a0b7caf80a511d03fb680bd2604ddc59ec0362a244ee77f", "help/modules/benchmark.md": "6c31f16fc3f083f29c65398c38b78609a3d94da168d30a93825af8ee9650eb0d", + "help/modules/cache-warmup.md": "3f3ac8252b0f808ccba046daa69fde3bbb438b35252a16fa47255f5624e0f19b", "help/modules/comments.md": "a9b9a4ff122af3a2842b2e85d007973af935a07ce721f933ddaabe1d07e0e1a9", - "help/modules/commerce.md": "21b0f353294be599d47647e539aa5760440ef2e7fd2b4efab80c4c02490446ed", - "help/modules/commerceml.md": "c74a3408799c72996c86dd86fc608cc9ab49467f5fa938a6f2e5ad1568085d18", + "help/modules/commerce.md": "de7b8fd3c27516044060ebfd96152f428a35851f2dfee62fb4edd10ee09f2ff0", + "help/modules/commerceml.md": "60a0a5c541b560b5a6a4ed8213d9145caace68c15be534af13f5f817eb5251ba", "help/modules/contacts.md": "8df21307a300a335aaf243cb42a107829114ac031da128cfdfc6f4b94452dd3a", "help/modules/content-packages.md": "8dde0ebc897b7891a6afadb6491f4797bef26622918addab67f0f4ebfd719678", - "help/modules/contributions.md": "70489671ec9c74b3ac2933b1f70982cf0fe59dc69fc91c667a70ad1d3783b27b", + "help/modules/contributions.md": "3213f68af10e1b6a200aa61a3aaa9dad270da7ccd675b43bf7cc1ded90b55e60", "help/modules/dashboard-widgets.md": "db967aa0dc13d9c72631e219b6b3769ffc350211eb0ed86a467e5a3baa1462d0", "help/modules/demo-site.md": "6dcb58f90c00d921ab1ab097424ea27845079d810f026a128f3ff9ea190982cd", "help/modules/document-import.md": "579d5270328a962c0d171cfebef07a9709153c2a992ca22b8f07637dfc2b3c91", - "help/modules/experiments.md": "aa42fb997906eb5e2e15f8d5ba61fd8c93e7f9368ac10f042d9917a2364120b7", + "help/modules/experiments.md": "923c3656188192e0cfde640d20c5e0a4aa6add4840e5dc0b747583b105e14256", "help/modules/faq.md": "ac536c5fdb5269f54f55475319d351c27993c37609769c92025a2e00be5c8c0e", "help/modules/file-security.md": "5e8fc01b5c1f8b73f117f1ecb305e2ee642db92edbbe932cfb416b16c7ff705d", "help/modules/files.md": "86305ab6e33640990d36f03ea39ba319d2984aba0bd7f300cb035ef497e94aaf", @@ -781,8 +825,9 @@ "help/modules/header-actions.md": "134cdd5cf4f8b1ef59c22b971b3d4faaf3ef4c493385100481e7e852d3e163ad", "help/modules/help-viewer.md": "175db88e150ee02dadd8b841ea74262490b8211c67bc42bf05cf1dbafba1a25f", "help/modules/interactions.md": "6c62a4abb3422609265b1898cb448dd6bd840edeec98f30fb0062c6d18808e18", - "help/modules/legacy-migration.md": "f3025bed1df1af4dc3ae99206681e26c5fa00a57fea54e4fd770d502e5304dea", + "help/modules/legacy-migration.md": "773acd5026409522bdd3a0d3fbbfcd915ca54849d64c97dbc0cbfc2a79fce36e", "help/modules/localization.md": "f6a58cba016a55c7a9a88c594ae25fc79828411dae13a70aa84a54860821ae43", + "help/modules/mcp.md": "a7b63a32773a1517de3c5bc328771bb3e2b9ff402f1004ededac2f94add25b02", "help/modules/migrations.md": "e144ba009df225508d5f73e9ce71e17d4a379f9ddff49e640c2c1234e389588f", "help/modules/module-php.md": "3594f19c10381b4d450b6ce5b9b1d83b2f413067d67f7cca334d3a14cdbf2a0e", "help/modules/not-found.md": "50e45e048e07e27190b8583928c8fe20cd77c5e3401adfc47b9b2effa1ccd799", @@ -794,13 +839,14 @@ "help/modules/polls.md": "4d13eabeb2a546ff036c8095cecd3e8a6781ecc0de7446b27c4b26b152397ee8", "help/modules/popups.md": "f95ab2440beafdce8801aebae32070e3b4d4f04148bcc9e95a0aa3231ac96411", "help/modules/ratings.md": "e4c8da2672ccec5ce085c507919fd016ef204427ce919f1a9c2b1c0146fe7c8c", - "help/modules/related.md": "007631556df983656542bf039cad79426bebc61ca2eaed2a90750e2f0954a220", + "help/modules/related.md": "5500f2aa6a7c95367e439565b14037d2b73ef4218ea0a824088b3f32979c8979", + "help/modules/reliable-events.md": "983074f9490bc5897acd0373e8c1c748a127ba54ea1248a4e3675caddfe7e4a5", "help/modules/repository.md": "e371ac47aaf81408242408d795df6cfdf62cbaffa232192cc4cff7dfb5e6c5ff", "help/modules/reviews.md": "a16bba5fee4d17f6fe0813f54c168fc98b4fc30050a349efd34a21b7b757b2b6", "help/modules/rss.md": "0f8fe5fa5bd4991b432068a5a520d4a45d41dd6a2c6011f7f293f74c8d9c1588", - "help/modules/scheduler.md": "96272f91cbb17b5fd12b463e1f5909bc825a226b0fd53e1a8e728c066fc97d07", + "help/modules/scheduler.md": "a39cf392ce9cf5af63e92063b95aea63979a24b27218a4b090b94834ef90b536", "help/modules/search-analytics.md": "0444e0dacabe408358bcdfda4b6b2a475095e8b1c0ffc1361ff58c4db0576d35", - "help/modules/search.md": "ff25a35ff67a27e4ea59baaa99a78826cc91d518c93ec44f690f405532ea6007", + "help/modules/search.md": "c1a130e487de27719a8171590e69fef6bf9e85136ae4d3ab862a380826c62e55", "help/modules/seo-audit.md": "b0fb78eda0e83c0711b7717fcc730e7bc0cb86c6d692f21aa749cc4975f0ee27", "help/modules/site-readiness.md": "2c7f845ece693ef14f23ea2b3b226f38ebfed305fc67bf842fae971f0e12255a", "help/modules/smsc-auth.md": "57a5c3686d7267c166edc858deb7144af4d3002eb6b9d01d8f2ceea5a9877e00", @@ -812,26 +858,26 @@ "license.txt": "b8093fa95967d0f6024a5334bc3119c694aba6f51be2fce66be6939f68c39e93", "recovery.php": "2ecded66156c70919f9e83451ede4bbc1a5d5e5636e9362b2317ef05f733004f", "setup/.htaccess": "a25c35a118024ed8b958455ccb123a5f899ad4f1103977e53d50f94b5499c7ea", - "setup/Installer.php": "f73d369b5629244e8a38895d1fa35ad9ed725523914a0c6034ed3a31de095483", + "setup/Installer.php": "44d515370518a276696b6fed8e342bf5488720d486c27304c41ae152e7967a5a", "setup/Localization.php": "e6de0faeaa493fc317907099df2ab58c76f7b643ac70ea233deaa5bcb86d79db", "setup/PackageExtractor.php": "57cb5473905992333936d7984221e5188cd72756d6e48206a42d96e8ab59442d", - "setup/assets/installer.css": "4b968e19d2b2b5ca1b4fe265f6ee78ff8812b4967751ad71501a7a20af43082e", + "setup/assets/installer.css": "79e43dc7123f37206429e8535d92c5069c53231402c67b63ea9481bbea60ab42", "setup/assets/installer.js": "892a982b872390fea21455562353408da906114b973ccbfc3a3cce06465ab1e8", - "setup/core-migrations.php": "520ba71a85b8035c52d340696e17203e3c89829b939b4e1129010b66e695a067", + "setup/core-migrations.php": "493713e0fc6ade5b3bf866a4bf11b2db1e9e64ab5c5859aaa169df5cd643b8f0", "setup/index.php": "63bff35919aa2f4cc9ac817b92020d01d5e8a8d6ec387071e0d1ece1edaedc15", - "setup/language/en.php": "cc62f651ff62f75a5c1cdc591d0284ab6e7271175061fa8a0e026ec16df06d7a", + "setup/language/en.php": "963dd284ee244e6d006dede8522c1118dc0909974dcd24d477fd06b7e1965fa1", "setup/language/ru.php": "8522b13c4f147804538eaa857585296db811c736e1361e6b403daecea05f34f8", "setup/recovery.php": "359ae9b79508a382bdde175824b18a5bdb0fd69ddb7776c7d18ac9eb826d127a", - "setup/schema.sql": "c23be35e4eb2e2fd755a5a359c44e3ddf9b33614e2d966b5676aed9770975757", + "setup/schema.sql": "c4e134bf6ec81dad3a46dfa6d2f990e10104e188f54ab21cdbd0a9cb4e53e2af", "storage/.htaccess": "ca761a92f2b6eb47feffcf200a8fd846e7fdd8abe065008d29beff0319ca3496", - "system/App/Common/AdminAssets.php": "89364e67c30bde6df608eaa59c20584e7979995710500e421cc27a54e2d3a262", + "system/App/Common/AdminAssets.php": "2dbf63a3b5e69ca430391e17420ef9b1db7f6329f28fe510e8b9da5e7b20f68a", "system/App/Common/AdminLocation.php": "d2729b0d627ddbdeb7f0c4b110664ed949f373946f80929c51688391c64352de", - "system/App/Common/ApiTokenRepository.php": "94c01d985776ba8c8a3b7c533b1c17425e2af713fb24df01d822bd191a8acff5", - "system/App/Common/AuditLog.php": "25ade9afb4ff843e341120ef3a204f6b72993cd9917e101237db8f47e245298b", + "system/App/Common/ApiTokenRepository.php": "2d750efef3e5f8624e3f307a6a8074886d983f324d74d6455eb3ed0061bb57a4", + "system/App/Common/AuditLog.php": "2961c0545d395e42d21119e1a8beafc0c3ffb3015ee67dd20edac4d1f302082e", "system/App/Common/Auth.php": "886b33e077babf3b8d9fc4c14bb4535ee12aed8a34799e16e4f73ab657c6cbba", - "system/App/Common/Auth/IdentityLinker.php": "c614215dc98de43a6dd31c7c4435cac3540acd886b0f450f6e0fdc1537276563", + "system/App/Common/Auth/IdentityLinker.php": "7da4d902405bb4ead2278e5bc3578cd0318422fe82318bec94257ed44d5ba98c", "system/App/Common/Auth/PublicUserNames.php": "0345d1f452e0a9accaa9b98eea71f27a428692a5b4f2ad225a10ad8bcf777684", - "system/App/Common/Auth/PublicUserProvider.php": "b24beb04d30756d8f7ed30ace4d0e5f70882e5a28989080b6c377baffd8a77d3", + "system/App/Common/Auth/PublicUserProvider.php": "5661296372762ad2b3a4297d4ce76f171c58ec4343ab585b71f58b856bdad96f", "system/App/Common/Cache.php": "bb322246cbfa12cbcdd67c16a24c55715ad67b31d356b4f18091c0aa1d00eb9a", "system/App/Common/CacheKey.php": "47400cd90d1880d061995bfea585b56210e18a870607d9b1cf7f0eeaccf15032", "system/App/Common/CompiledModuleRegistry.php": "9ab055fa759f4041944bb94bfc2e28361986a09b742210981928bd6ee15f758e", @@ -843,7 +889,7 @@ "system/App/Common/CoreUpdate/UpdateRepository.php": "adb226ce961f920ab97cf195e277c9e60a93ab846bde0e34492ba66b53caae16", "system/App/Common/CsvLogWriter.php": "9deaa3e2bd85eec300c65e89e22549f48aa9e2850ad50b2ee8826f8d4fa53aa1", "system/App/Common/DatabaseConfiguration.php": "3eec09e9a0d9302761ca6b9ebfcb47b01aaded1bb7ed5834b1d55cac806918d3", - "system/App/Common/DatabaseSchema.php": "2958c57ae90c35824c76c17e4230ae3041a9be3d06b1a2916264ba5f8b5a834c", + "system/App/Common/DatabaseSchema.php": "a71320db73eace5f630f2106d402565702c88bebae8056aced6daf922a78ab88", "system/App/Common/Db/Core/ConnectionManager.php": "dcd609e118916eb8ae5c0f2d5e38e18d3f9631395342de44f104dac3c47908c8", "system/App/Common/Db/Core/ErrorHandler.php": "3da721f57d469fa6f339bb99cea8fd2a98c2bf11beccaf2d574005090a29dbd2", "system/App/Common/Db/Core/MysqliDriver.php": "130b99106ef29ceb69939ea5a5dd820d2745333154b28daee7da21bac835466f", @@ -866,7 +912,7 @@ "system/App/Common/Exceptions.php": "83b5dbbb5791789a9ba5fa69ac049037742e5d8622e40e125860405935f7e0e9", "system/App/Common/FileCacheInvalidator.php": "24d34113c9909e3c56d74ea35ed61a1744c20f7fa7ecaef49761f2a28a774a18", "system/App/Common/Flash.php": "8fcdafb920b374a6f346c10a42e3f846003fccba97f4bb48fa7028aae2a0091b", - "system/App/Common/HookCatalog.php": "3cb5a62c901ec173097cea78935f4e00b05473ecdcd4e9be2ec0f4ee6ed07e28", + "system/App/Common/HookCatalog.php": "52e151f1b41856436d13cb6fd6610ffdec264225a0ff7bd29761ad405ece92a8", "system/App/Common/IpBlocker.php": "5536f4802233588671fa5aaace1c017ae8f52590b64213d5d9681a608ff3c3b9", "system/App/Common/Language.php": "587672d76ade8b5e1b30e98796e3656a833515db4d09b44717da443e0fa7ac1f", "system/App/Common/Lifecycle.php": "4a59040f213232dfc9cc5c3a9bee491eab5916b65accb47c9972ecfb2573b68e", @@ -877,30 +923,30 @@ "system/App/Common/LoginThrottle.php": "de6550ad689f5743dee92e5071bbff1a12a68ffaadcbe08e2e5fffccc51f98d3", "system/App/Common/Logs.php": "d23e9a3ba1ef925b37b756d4dcbba47b42edaa0ad732804b4c99d1dd2c0d8ec5", "system/App/Common/ModuleLifecyclePreflight.php": "66749f25d63dde05a06cffeb75a0a22c5ead68e51ef6ac71578384fecd8520c9", - "system/App/Common/ModuleManager.php": "5f5ed1aa57e6d8884930ab4f5bf18077889eeacaa93ba66febbc7bbf9071defc", - "system/App/Common/ModuleMigrator.php": "d9aea1aa006a1736d8314823053ecbb970cc7553f9af315b8b6fc092ffb07d83", + "system/App/Common/ModuleManager.php": "20952e53cab9b716ad2fb518021683df7f6918e17f38e8cc0ade1d2e9ed27776", + "system/App/Common/ModuleMigrator.php": "121d2c21fc491279b36cbf5159ff401ba47ab7f57d7f3d502cedda746283a364", "system/App/Common/ModuleSecretStore.php": "6ef62294077db7f09b85ce26f3167b39cd980547d1f21bee213b59a9c7e2e6da", "system/App/Common/ModuleSettings.php": "b3280ad547e59bfcefb4a338339a36905fab5db1548345b0f00e257d275a6294", "system/App/Common/ModuleTagRegistry.php": "9e68b64303825a25ef4f71f31a7adedee9d2752e4d1d06162605da1dc510a6a5", "system/App/Common/Navigation.php": "1a1b0a3552d941dc0fc59c334ab705e86a73ee96ee42871dc958012bec1d81f6", "system/App/Common/NotFoundLog.php": "3b1cdeefd05ae250e90d1a05dd9e1bc0ba2cb0c2cbb89d504fe009151f20452a", - "system/App/Common/OutboundHttpClient.php": "ef46209e2b093fc152000b050bad6d5d497489486e09abc78cceabc0176ed2d8", - "system/App/Common/PackageModuleRuntime.php": "8afcbf0439fb3b69b6b9e098109eeb2fc1521d3435d5d2d654085d5bd2b2a470", + "system/App/Common/OutboundHttpClient.php": "5d1182b75cbb507a962fab64c11ca3922826bd9810dfbbf3903589edc686e9ca", + "system/App/Common/PackageModuleRuntime.php": "5ad492b9053bcea4d3bd4398b11fb2d976272e2036d379bdc24c19dd5fe7486f", "system/App/Common/Permission.php": "8e2241bca829de0104741dc2518860cd15489866dc310437e07aeab2bf0f0355", "system/App/Common/Proxy.php": "707564208ec8c24ac2ffb5bf41cba34f1b39059ce6bda31a69aaf4b116d864e2", "system/App/Common/PublicAsset.php": "06f9ae414f7336206073e07597669991098114c396e80748c654c82b370a39c4", - "system/App/Common/PublicAuthSettings.php": "521599642c667a00b610dabea8a4a8f2ef79af48fb6c907878882f8252297040", + "system/App/Common/PublicAuthSettings.php": "d73f2ee75141e055be0968d190ad11190a0b1275cdc0330670274b46b8206f07", "system/App/Common/PublicBootstrap.php": "1208a8797bbe695d4607fa804f654402883bd47f926e0a39fa8766928c16e509", "system/App/Common/PublicConfiguration.php": "1daf6c26de04277f8618be4e25453aa479de9f7dfc09ba770d405eb7f9454760", "system/App/Common/PublicModuleRuntime.php": "5aafede8a2c0cf35023419a024566ca453676ea2dd9274a4d5ca99faa9273d15", - "system/App/Common/PublicRouteRegistry.php": "dc5129b12f0969a32ed605c8bece174fed11b679ce4328d426099e3e38ccf688", + "system/App/Common/PublicRouteRegistry.php": "6898abe0018f2630807f9697306ecfa2d63482f38832ba556b15eea21fa14222", "system/App/Common/PublicSysblockRegistry.php": "0b9112a40f98699db6d2ae802e346fee27ece77de8720b2dc30d8b5cf207ed81", "system/App/Common/PublicTagRegistry.php": "787e3cf4014b501fcbf24dcdbe552a0247e07586826f5ae4584b86bb781b8d5d", "system/App/Common/RateLimiter.php": "906313be7ff8e2570dfac8b7c261fa74d6e814e052c40ceeac1cd840c7cca3c8", "system/App/Common/Ref.php": "e5e55093871022e86a7c8e8841e2e150922a7454500668dfb6f30fd50aa6044b", "system/App/Common/ReferrerLog.php": "a2da447a548011c9ae6f8be2ef4aacac2a3d4c38b62a19d7ea5442589cb7febe", "system/App/Common/Registry.php": "7b4ddbb3f96a7ad54a8d43e151cb595e598d3620b368d21721927ce9d54e1ea3", - "system/App/Common/ResponseSecurityHeaders.php": "0c20afd7d42f0d878c53f6088dbe4c03b6fd26e57d82a040c775fef180b24a7e", + "system/App/Common/ResponseSecurityHeaders.php": "b159a7105d3c1fb3ffdefbbc5997741d7b0f83bfb26fabcac96f61b53969364f", "system/App/Common/Router.php": "4b0dd3fd9f732177070ce3326b691a8decfbe698aa0d8cf687a18f6c02b73d91", "system/App/Common/RuntimeConstantSchema.php": "08daf898ed9bfa12940adf8ddcd3e0dac811d28de8b798770afc42cbd20f1125", "system/App/Common/RuntimeConstants.php": "decb1277dada75ee2b5c7d531f064db7ffecef5b3ad71d306111b2a45121d34f", @@ -912,20 +958,23 @@ "system/App/Common/Settings.php": "d739b499a2d3c893f387cf3f5033815b863edf08414b1565063fb9d5e7d53f0e", "system/App/Common/SettingsRegistry.php": "0a24cf4e55e48d05a8af09ac00bbcbcc1179385254af8fd914e754964a1362b8", "system/App/Common/StoredPhpRuntime.php": "2bd4fa83c970cfa0b8002b11cf36bd0def7b37ea19c025f7c6b2f37f0de38286", - "system/App/Common/SystemTables.php": "dc1830e972a32a87770bcfa3b4240524f49bb9f93df75d48e8ae05c0cc2004fa", + "system/App/Common/SystemTables.php": "85e95425b64d35bd8a3a5d4ddb14c1367ecbaa64ff6aaf4378055a1019895806", "system/App/Common/Template.php": "5a0cacde5059fa9918321e6baa71a09a754a024055dedffae161486c51902f45", "system/App/Common/TemporaryDirectory.php": "4aad5e498b0ef5483427c18ec6eaaf9db64ffd1348a0ee3f445da080987e4bd1", "system/App/Common/TrafficAttribution.php": "cef0bce9cbd2ca3d8dd297cf1b969669ed1cb996cfa175841ecc6836ca7eb8e0", "system/App/Common/Twig.php": "c6b62e0d1a9576082babe49d9d31e8f9f63016f819a418dd723839851a32c455", "system/App/Common/Twig/TwigExtensions.php": "b9db3c89ea361b8b4ed754741d5692fc05831a7523a7f9832a9128c313a5b8e4", - "system/App/Common/UploadPolicy.php": "a8ad388681a65a31981767c2d1c17f635f4c490a17803f35d3548a47758c596d", + "system/App/Common/UploadPolicy.php": "c6423e3300bab55fdf54bb59573111fc61febe0d10c3af9b143a88f7d0732e49", "system/App/Common/WebMaintenance.php": "a91f9ee8371da6c6a020d9ce39e1f9f7b517b2644ea51ada65f1f487ff337e6b", "system/App/Content/BasketTables.php": "1c4d1d9a5ff894c6ebb0edbcfd7bb6f791124e46dd8bed3b70fb0ef2c15069a0", "system/App/Content/Blocks/ContentBlockRegistry.php": "53bc204e7dd1f9d61781049627162341a4d7e656783915161fa5c4eae4cfa228", - "system/App/Content/CatalogMenuRepository.php": "1ef7e4fac76f1b1571eb85611002cbb18bfd61829471fb189024c651efed8355", + "system/App/Content/CatalogMenuRepository.php": "d2b384255fb9e67264f071064dafd645913a73ee640e559d600b1671455443eb", "system/App/Content/CatalogTables.php": "5abd93b17142ea5c57a0726c8c6e3dcaf896790cf4ba31ea69be19a828ac9517", "system/App/Content/ContactsTables.php": "d0042c761b854903b483e1b3a30d6014c596eef1a03c40cdbedae4fecb3c25d8", + "system/App/Content/ContentPlacementMap.php": "b4328ef770c98ab8f93d395d2d13ca5ff54d94a2679977ed2615e4d33c50d55d", "system/App/Content/ContentTables.php": "a63105d8f3859921a9c1d17a4b8c69d3950ff0294de144814bd2b4768d236fde", + "system/App/Content/ContentTagDependencies.php": "e96882109203f4db60559cc324100ce5596c8f8a65571ab53b8b86cdd3d89fa9", + "system/App/Content/Directories/DirectoryRepository.php": "47d4f7a91841ff437c4448aa71f6a2b38c621dcf5a1204bd2ff4948f3ad671c1", "system/App/Content/DocumentTerms.php": "353d0a36e0cef77d4016f9edf9c2a2a3a27db953a3eb4cb91d7ec49af18b0609", "system/App/Content/Documents/ContentCacheInvalidator.php": "d2bfd72d4df1a05e281b88543fabb8356ffc9799d37286c3767c6a0173ea91b1", "system/App/Content/Documents/DocumentAliasRegistry.php": "aa87cb8b65990f480f5b4f5dea83948dec9758fb4069b43eeba4be7f7752be51", @@ -939,12 +988,14 @@ "system/App/Content/Documents/DocumentMediaFinalization.php": "5ea218f79f835a4fc25da0199aab87137e620b60c31d8e2f587a1b911b3af6b1", "system/App/Content/Documents/DocumentMediaFinalizer.php": "785487028e221726c6bc15662f83ed985701a8e5d21e9fc9605e816ef4c835b7", "system/App/Content/Documents/DocumentMediaPath.php": "23b40c98d3cf9a73df3b7f7083b58b60dd80b8bda71e3e3a6354342cc72322a8", - "system/App/Content/Documents/DocumentMutationService.php": "44e83fad0d2cd04df2d1a9338891984f52b06f0b886caef59ef2f6d1281c6784", + "system/App/Content/Documents/DocumentMutationService.php": "52e8f539cb62a3d787bdbae9e2df5d8d21bf75a39fa46c1d25545884b2b022ae", + "system/App/Content/Documents/DocumentPickerRepository.php": "0e8efb5b42aadd74725213b8302b53b5147d7e0eb2e83d301a5f073bb0c13142", "system/App/Content/Documents/DocumentRevisionPayload.php": "c7f83350de98b6d98531508eafecc4a2b5a0551c7f2ba77a9e5b954fd1287ea0", - "system/App/Content/Documents/DocumentRubricCodeRunner.php": "093fd372fc01f0ff1640379715c058ef52db2451e66b65bed84cc2d51b49ec2f", - "system/App/Content/Documents/DocumentSaveEvent.php": "8a2798041cd72be693278cf91c04358a7f486d1ca8035b17651455f199312492", - "system/App/Content/Documents/DocumentSaveEvents.php": "52fe587fc4419b5ec86144f522b3028682b384a52dafb9690e0928f8e68acdb8", + "system/App/Content/Documents/DocumentRubricCodeRunner.php": "a9c04196b76ab91c7b1f8b53afbd45fc6727805c953fb5fe5ff36b69846d389f", + "system/App/Content/Documents/DocumentSaveEvent.php": "ad30286d10b734e090732f84f0e6a77bff050acf653949140049418dda8663bf", + "system/App/Content/Documents/DocumentSaveEvents.php": "79a9fa78819938f179bc24121992e7d44306c51612734d9ef10839b764a5d394", "system/App/Content/Documents/DocumentSaveRejected.php": "4ebdc1c5aa622f6389b107320a7786b8acde89438f6c56a660317deb623cec5f", + "system/App/Content/Documents/DocumentSearch.php": "ba8d1c7a28878e3b7e2c96aff07c59c2ca9b5b5eabb4eda99de6362b4274d54e", "system/App/Content/Documents/DocumentSnapshotBuilder.php": "1d9437433d721198fbd18c6cb141657da93c569fabba46bc59466981b68b5ee3", "system/App/Content/Documents/DocumentSnapshotRepository.php": "54faed521c064f84cc4017e65ea0dba8b9bb34571240a90c083eb076c9a11b1a", "system/App/Content/Documents/DocumentSnapshotStore.php": "e61a7873204ba76b50d2ae7f087a59442cb93fa10ad5c3f0ac0087d941dd2075", @@ -952,10 +1003,11 @@ "system/App/Content/Documents/RubricSchemaBuilder.php": "a391211bbdbde83a6b8f4ce2ccf98bad2ad2f5d39f35edb5e262782729dd86f2", "system/App/Content/ExtensionTables.php": "7e59c929815afc570dbd6e0af754339ee94a1cb32b928edbc6d50cf34bcbe085", "system/App/Content/Fields/AbstractFieldType.php": "a6be3a44263f07cddcdbeee600e48f224cbd948948740cd7cdda34c2d7fa394d", + "system/App/Content/Fields/ComputedFieldEvaluator.php": "8711b89d8d5c99ad847e11fa5c1d80e890cfc0cb35264d170b1fc3893f2df288", "system/App/Content/Fields/DocumentMediaFieldTrait.php": "4f5845f44cae6bfa3333cf39ba56edeae580359dc5edbc89fc64cefa7912eb34", "system/App/Content/Fields/DocumentMediaFieldType.php": "f2a461a3780354159a915f4f107a479c7326f255357ee82eec25d3bfc658b4d1", "system/App/Content/Fields/DocumentRelationFieldTrait.php": "eb397fd4aabeea93cf4d280f7c680f10d92a17596bafaea8c3eddc4bedb17c78", - "system/App/Content/Fields/DocumentRelationIndex.php": "40551bf019428d1f6f81f07f3b8f45d0ebdc349387303e4d9085c413d3453444", + "system/App/Content/Fields/DocumentRelationIndex.php": "981b582526d7dae372606c1459023027165e7cb47f5bac14e4735184c652568c", "system/App/Content/Fields/DocumentRelationValue.php": "44abfd5820a0e58f7d7bdfe3cc460bb0db3ff33433bb94aa50dc3d4ef9b9152c", "system/App/Content/Fields/FieldConditionEvaluator.php": "93ef44322ee4da475a2d7b7c0964d13578a67ededb3da8a5d7e5ebcd05aecf66", "system/App/Content/Fields/FieldContext.php": "97e4d4171bba1ce5c5185068c33eaeb91fdfab670b1ce3fc9f56e10f5cca1d0b", @@ -963,7 +1015,7 @@ "system/App/Content/Fields/FieldLifecycle.php": "ee33d7f6e821f71d39bba73a73782021a061edbdd0be7b34164f8b94ad505904", "system/App/Content/Fields/FieldRegistry.php": "c993388ddd8ec9cd8496d3e812da83a271ad30271a8ee87b79b2f94df84aba9a", "system/App/Content/Fields/FieldSettings.php": "eb849a290e61d7149f69475d1319b06e5b19cd56f1cafdec78f95f37a20e475b", - "system/App/Content/Fields/FieldSettingsForm.php": "c3988a6fd9b51ae3a2efbbe77fdbc9539090c8682c97329d4d40c64a9c1cd899", + "system/App/Content/Fields/FieldSettingsForm.php": "e436743e17fab7aaf14dab6ce034fa2ece2f5d165a4aa04450d034606c8e2a73", "system/App/Content/Fields/FieldType.php": "47979aaa49b773e2a00288ffccb746218b58ece8fa6d178a90d0cc9c853055d9", "system/App/Content/Fields/FieldValidationException.php": "1a1d6d5278d6f8ff697e5afe75e0a6f59db2b12121c93b786fd87f8b81287717", "system/App/Content/Fields/FieldValidator.php": "5408849670818b379a66d5db9b7aa0d94ebbd8438d7788124f62cd221dae5561", @@ -979,9 +1031,10 @@ "system/App/Content/Fields/Types/Catalog.php": "2126e5a6bcb097f96c5851a8b84a5fbbf04c0c83de200491b9a68d51caf5b597", "system/App/Content/Fields/Types/Checkbox.php": "ad762af437e80054e0cdc1e5e16712899590c2806d6557b1dd730088392a0e7c", "system/App/Content/Fields/Types/CheckboxMulti.php": "81fe85ee7d0d55c26d9bd260a0e75aaed4ac516d6da77cf3071bbe309b1c1cc5", - "system/App/Content/Fields/Types/ChoiceValue.php": "fda95e47a525e6e6e49b6a74fc23876b657be091b0ecfe3bfbaface6af9cb662", + "system/App/Content/Fields/Types/ChoiceValue.php": "e3dbb710847d2317cd08b95b411cace18c3b01312971991b3073f6f61ebf599f", "system/App/Content/Fields/Types/Code.php": "b517c1ad6bdde09ddc80ad83065ad30575d8a9fee804bfc54b3324af9c334d98", "system/App/Content/Fields/Types/ColorValue.php": "8a68a4fdf628dc30922357bb0918235f373e76034d137afa270004edcd947740", + "system/App/Content/Fields/Types/Computed.php": "82ff8ff37fdb9802a6626edb7abfe97e0867901157b23d05fb75a84710aa851c", "system/App/Content/Fields/Types/ContactValue.php": "b6f8533a8de393b4c760ea69423b8bd1c1fa0717bc5a608cca7710f58c751f11", "system/App/Content/Fields/Types/Content.php": "e7e41bca3dc574e5735446593ba875d59ec1baf4769cb130ecdcecefda65709d", "system/App/Content/Fields/Types/Date.php": "4952e8ff31abdc8d133e6f812e075847a49790c9fdd1865357cbb4080b0bb8f0", @@ -994,23 +1047,23 @@ "system/App/Content/Fields/Types/DocFromRubMulti.php": "5ebbc0abf7c5f0306d46c01e2173f3750c418377883605a75d4deea3be0f9218", "system/App/Content/Fields/Types/DocFromRubSearch.php": "0b91d898710c1829de2a404e4e259c0cf926dc7e987be9eaf2e535f9ab86cfb9", "system/App/Content/Fields/Types/Download.php": "8906a382c05547afd112778b02f0fd21a1061bcde4e3b72d7e3682acd13fa88f", - "system/App/Content/Fields/Types/DropDown.php": "e8252d211d9e30478352a7eea14f758f054f3c03b12b9874cff2390315e1cfa5", - "system/App/Content/Fields/Types/DropDownKey.php": "b50c064a38b10da7065851ea9b8f0fecd74d6a438dd7a64ac4abfba89ae71282", + "system/App/Content/Fields/Types/DropDown.php": "8f2b41db0a14a14d24b48b0d83952cf19ce47032f90be442ddc18594c85ee4bb", + "system/App/Content/Fields/Types/DropDownKey.php": "ea2f1dde117704a44034ecb98d7d481d8f02c7bc130782dd9e9320d429779c61", "system/App/Content/Fields/Types/ImageMega.php": "c6e17503fabfeab926ae695b4e2a7c5dd99f60dd5bf0ec84b04bec6b96c6292a", - "system/App/Content/Fields/Types/ImageMulti.php": "cc1d9a5d8367e111a3b4da9eb5c7112a78fa438be505e5f32ac5ad579e2c7a2f", - "system/App/Content/Fields/Types/ImageSingle.php": "a4592703b10f25e1a97e82d1cbf79515233a8ce93d35598f9c77f37db70f4c21", + "system/App/Content/Fields/Types/ImageMulti.php": "ee1312c1f59a35af4ae37f9444f4875752ed09d83692006c2f17858a33b74c48", + "system/App/Content/Fields/Types/ImageSingle.php": "54c1253fcdf6c3726b2f6d4881d0bc7844fe3279e50f1d796d2bea14ed93dba1", "system/App/Content/Fields/Types/Link.php": "6c3b7031d8a1b78371c041b142a90a373217afce12e637d29cc0a6b4ed126d7b", - "system/App/Content/Fields/Types/MultiCheckbox.php": "26f4f9d1bbd7cbc24bcfb827565e9415e27f31fe1a2bc644e1f42760c7d1f213", + "system/App/Content/Fields/Types/MultiCheckbox.php": "040dcf7824d15a5bb9cd032ce2de171a91c8f4379e3482a85a59d6c7b34fc2dd", "system/App/Content/Fields/Types/MultiLine.php": "9cdba0fb14c1053a2c88c667f436eed2da243836d49ca6c3240612f32bdfab52", "system/App/Content/Fields/Types/MultiLineSimple.php": "0286b13ad1a2e83b5fdc8b11f05bcb99b80b38a018650c25e58141785fda0fda", "system/App/Content/Fields/Types/MultiLineSlim.php": "4c39e5810a9e2df82a7e03af7eab36b07c20c863fc31d3aabc2e172103b41d84", "system/App/Content/Fields/Types/MultiLinks.php": "a4d1ae15f0c23934a5185fe7407605079873f7a018c6ddfae0998f2d1c3312f6", "system/App/Content/Fields/Types/MultiList.php": "1ae0262a38c7b1b3ad67688bed05beb620745969b36fa5ffc6b7b052f16a40f8", "system/App/Content/Fields/Types/MultiListSingle.php": "45b86fb20ef18d9ffc6d1f3281b7ca8e6593269e778383cab8cdda0cfed2b9a4", - "system/App/Content/Fields/Types/MultiListTriple.php": "431c96998d0d9316a63c9521d5c2b306f06501576632f4deb5c04eb3c25e0891", - "system/App/Content/Fields/Types/MultiSelect.php": "cfd50ea68a31fbbfb069e4830077b0183ddc7686efdcd97f7509e9108564ec18", + "system/App/Content/Fields/Types/MultiListTriple.php": "8118774650d49ed98cf00329bf3d53b20a05ed7d0473909eda4244943be550f1", + "system/App/Content/Fields/Types/MultiSelect.php": "b38df4b46b529b6c60c9aaf001faaefef5ac7599cf68ec0323a3186609e37d4b", "system/App/Content/Fields/Types/NumberValue.php": "8fe7bc2aee7df6031598384627c8c38b8b4a5b04d796c25ee8aab768805d34ad", - "system/App/Content/Fields/Types/OptionFieldSupport.php": "73015455939f82179cc0b861d3b94faf6fac27bcec303f958d252c31f8744871", + "system/App/Content/Fields/Types/OptionFieldSupport.php": "ad45ad626daceb7a83f8b0d80faa90dce4727d7d344a0da119e3e6b27fbef54f", "system/App/Content/Fields/Types/PackagesValue.php": "b83d3ac394c42204ab89d4edcc5bba85e95c8850d07839f53273e958db346c11", "system/App/Content/Fields/Types/PeriodValue.php": "afe067af7705b027652e2210ae784977a4fa00344667b672c3314d463d238197", "system/App/Content/Fields/Types/RangeValue.php": "396b6dcc4f4b4a1875c033c31dbf7b1e4ccd120a79bd86cbde813d7ec57bfa40", @@ -1023,32 +1076,46 @@ "system/App/Content/Fields/Types/Teasers.php": "cc49fbf43feaa1e875df65a8b7c3dc47c044ff52ddfdaa6bd3ece8adfdf9891c", "system/App/Content/Fields/Types/TextToImage.php": "5a272c24db87738b6d0fb81f73f9af6cb74efa1baa03d74b3d6c8783c0fd5724", "system/App/Content/Fields/Types/Youtube.php": "4724937ec212360e8da7d28f4e1a35e2308325de77b867545236a6645d9278b7", + "system/App/Content/Introspection/ContentDiagnosis.php": "dd10cd446ed49202bab64e586cd09d7b385d9875b09fc07d20c2f779775ba33e", + "system/App/Content/Introspection/ContractRegistry.php": "b001e60d8dd085ba133301ad33285a130daf7ed69519413e314d21e01142c94a", + "system/App/Content/Introspection/IntrospectionSanitizer.php": "99fddd82e5f107fe1b8dd1b41c174a9e2dc36e72bf28305041883338ad9db8e5", + "system/App/Content/Introspection/IntrospectionService.php": "5a5de2ab64f6e15c1d16c6d3883783e18c3439383d14ef626f4b11542658e1ec", + "system/App/Content/Introspection/PageContextInspector.php": "aa474fe89a8da647d5935ca2798269e642c99efc7fa7152fe3e288b24da561d8", "system/App/Content/Packages/ContentPackageWriter.php": "dea33706a29e11d2a04ca6904bad49f8ff6c062b2cc1eeef1bf9ba18f49adb00", "system/App/Content/Packages/PackageDependencyContext.php": "8a1f8ca6bbb7add9d8af11d28cdf7137c9a9eaf25b528b0259cf9bd0be00c8b7", "system/App/Content/Packages/PackageModuleProvisioner.php": "28a922ac5471d86ea0c467de11164c97fecd0ee432814e79927bb875995629cc", "system/App/Content/PaymentPrograms.php": "d233564780b9ab9655f8c41c490aad207c95731f36e6967fb52ac54b9c89e52e", - "system/App/Content/ProductShipping.php": "4a33a877983a8509ce35fdd88ed95db43591d3b35d5e4f5d9a5b67c566434c9b", + "system/App/Content/Presentation/DocumentPresentationBridge.php": "be90d44accf2f75435a62e8be5e79184e095708a3601784daaf87aa4e05ba501", + "system/App/Content/Presentation/PresentationAssignmentRepository.php": "a24059e979dfacf7d06f72a46a0322f50b921d0361ed8a426a64b393ca387cca", + "system/App/Content/Presentation/PresentationDataRegistry.php": "7159542177443ab7e84ce85e52764bfd7df4448320059175dcbb151ef40eb38c", + "system/App/Content/Presentation/PresentationPreview.php": "57baa912b0c121efaee7f8a524ecc44462fc25ac3170e54e747a45210e8dbd56", + "system/App/Content/Presentation/PresentationRenderer.php": "989a0e90da4e65790c3152c8b584b039f4f1f101d5aa2c288080e0548a68f357", + "system/App/Content/Presentation/PresentationRepository.php": "bc5fb81cb6068ac54551463e563637ad9d75fc5a571d27e8642ed19e7a43b118", + "system/App/Content/Presentation/PresentationRevisionRepository.php": "061af7ebd841a44a24be91e6ae3d3b497347bb5333819c74712348b81c40c2df", + "system/App/Content/Presentation/PresentationSyntax.php": "aa15536fc06559d34b9aa6b248136e8314f75997b467083dcfff4f1445fe44f9", + "system/App/Content/ProductShipping.php": "8a92b23991b502a97ee3d718add3e87e05382ff80aa83e3c657f54400c3745bc", "system/App/Content/PublicShellTables.php": "f89cd224436da9d1122c988a3a0ac19e5bef85351c51649506fe16a05c514cd9", - "system/App/Content/PublicUserTables.php": "56745969410ab9e8e4272103657f805e346336db34166461de7f2113a6a52ac2", + "system/App/Content/PublicUserTables.php": "f44a184075c9f978564f4b413cb3e89cda5f17528459d52258f458cef87941ab", "system/App/Content/Requests/NativeRequestExecutor.php": "988a1635bb4602b55edb89a1fb8dd26ee0537c074c77fc86b00ecbd8800517ba", "system/App/Content/Requests/NativeRequestPlanCompiler.php": "9e583cac4c41ced0c105d27c76786857ee50d15bb86d0049e6562924eab78779", "system/App/Content/Requests/RequestConditionCompiler.php": "fa076e03e00907a7c47c6072bfb8f6a8406378c723e331fceedfaad59068d58e", "system/App/Content/Requests/RequestConditionValue.php": "5a6122e53c9c4dd8d60691248c27d36057a77170c0c8bf0d8dc6a6236600f7ca", "system/App/Content/Requests/RequestExecutorComparator.php": "11ad4524e3c86e270b343d5b52960a5b98a9d602d78bb199e92f69f4c64fb47b", - "system/App/Content/Requests/RequestMatchExplainer.php": "884f01b19bf0a8cbee7694a799088b7d9d63297eb1980e79c797661f069ccff0", + "system/App/Content/Requests/RequestMatchExplainer.php": "f1d977989d388e61e18299ab28ccb688263d56b5a8a108343ef54eeb4f994b2b", "system/App/Content/Requests/RequestOrderDiagnostics.php": "cf11ec143b145457dd004fc1c2e65c8215fc39c42519d90f291987e62596490f", "system/App/Content/Requests/RequestRendererRegistry.php": "11b8496d0135fc54e86b3442bf1b2871a0e6010dea54ac2d3ef05f44cf1bf9ae", "system/App/Content/Requests/RequestResultContract.php": "4dfcd11227117722651f9f7e7c54cb7458754ccaa64b31b6f8deb161e7704a4c", "system/App/Content/Requests/RequestRuntimeValue.php": "9f0b9eeec1bf32cee8c13e1298e6b64c5a59f51c05d86b798523125a91ea8274", "system/App/Content/Requests/RequestTieBreakerAdvisor.php": "bd547d4dd74c45246e3a49bfef3320964d723c520fce3339ea5884b89aabd08f", "system/App/Content/Requests/RequestViewPreview.php": "ab80344f456d50aef43716572179b411a60af47523a71bbd45d317cc5967e899", + "system/App/Content/Revisions/JsonRevisionStore.php": "46b07200bd19bf0fe1d177e47ad84ed4de531b6271ebbccc80a9ff40c44b538f", "system/App/Content/Rubrics/FieldSetMerge.php": "15ae21c53a11a3c40f59abe3b117c640506def0355964216ad7350f8cf8e66d5", "system/App/Content/Rubrics/FieldSetRegistry.php": "a9df395474b9a28cdd1dd5dba40fe49e485835218afe472c414b9696094b9a70", "system/App/Content/Rubrics/RubricFieldMutationService.php": "3fec278925d14f7ec2efed63ba51cd91def333220ca4e18f3a42a202a8806674", "system/App/Content/Rubrics/RubricMutationService.php": "e3190ef1ddd94bdaf077148ce84c5758b2a32683369730a2b847dbbd0d72a74c", "system/App/Content/Templates/TemplateMutationService.php": "8c7e52202454e35eb0c8176325cc23ba760ac40ff0430cbba5e1446c83cf7a87", "system/App/Frontend/Auth/AuthService.php": "58a551f0c372194ffc2cca3e12f1e9a8ab743d07682f6b2c086ec1f7bc77ad54", - "system/App/Frontend/Auth/Controller.php": "e6e44f8db67f822edda968f9503790ec6c80142c0e3d0fefe61a5f3eab186234", + "system/App/Frontend/Auth/Controller.php": "968a682faaad88e76f127b7381a183ee393ba367a51e40cd17395bd69d4fb4e2", "system/App/Frontend/Auth/Feature.php": "60ff394cbf95ea3ca3f35476a6eb2bc4016fe472be85cd11ebac117635a54440", "system/App/Frontend/Auth/FormTemplateRepository.php": "97882ee4b46e0c4e31493a44831564409b5a64c096363aaf812f181d13abae64", "system/App/Frontend/Auth/OAuth/HttpClient.php": "b09acf92b312bd05ca78500fccaac3b77d36a1046d64d3615a00d60af4e3594f", @@ -1071,7 +1138,7 @@ "system/App/Frontend/Auth/Tags.php": "92fc1c466e720be36a0adce5a735e47c94bf88d9a3e70ba9d62196ef63e639f5", "system/App/Frontend/Auth/TokenRepository.php": "5e09b154d4747bfb3d2795a9b449ae4aefffe47508edef92d1a7b392c0371207", "system/App/Frontend/Auth/UserRepository.php": "21bfe56cb9f2f74ab9c58947cac9e8132159afddc0900d7d7136e919c3482bac", - "system/App/Frontend/Auth/assets/auth.css": "29724588981e3127908cb70b33fbc9711894e1e716d9618aa14309b172d65070", + "system/App/Frontend/Auth/assets/auth.css": "a16949511a17462d9157945f2ceccb445254b34243e9e368796dd3137aac49c8", "system/App/Frontend/Auth/assets/auth.js": "840c7322fc21f9a8dd61af8cd16e0ceed36d6beaeac45d948bedbcb87b6012c4", "system/App/Frontend/Auth/view/login.twig": "bbe2c12ce95f16cb2b2a07f945c8b8798e9386e9e3cbf16ddf5d0a3ca3944016", "system/App/Frontend/Auth/view/message.twig": "4e9a660af7d0deae9424a07550816c401902e75279414b11657e0eddf573deea", @@ -1086,14 +1153,14 @@ "system/App/Frontend/Auth/view/remember.twig": "fb170509256e4ecf13ebe5f64cae8cbd6ad8ef143abe645f4bc1f269e0d8a0fe", "system/App/Frontend/Auth/view/reset.twig": "5dff5378aa0e2203fd67fe9f1dd61586ee51e3d3f6138011b9d2b5dec4da31aa", "system/App/Frontend/BlockRenderer.php": "1d23d7ff85ba1052f896e204d1885f99ec2c0f6382e01d5d1903d69fe661305b", - "system/App/Frontend/Bootstrap.php": "f67e5d9aa1dbbf228085b67ba4258169695ccc349f4c4a38ed9e1848e7e666dd", + "system/App/Frontend/Bootstrap.php": "7cdbfc2855a819b80e742eaa15dbbbf9d1f29ef47fda18860c82ea4541fdb8a4", "system/App/Frontend/BreadcrumbRenderer.php": "6db4cbc31bc4e887a8ab4222c0359008dcc8d08bca969e9e3c3a098db12794b4", "system/App/Frontend/Captcha/Controller.php": "eb398d3bdc711a0543dd293b00f250e05bda477fa4577e76b33fafc23f7d2eb2", "system/App/Frontend/Captcha/Feature.php": "8e31714dc3101a3da8bac23989cba32ee9776a9f1fa5ec888a50cc65c8494972", "system/App/Frontend/ConfiguredPaginationRenderer.php": "df4d28a386e4180fd0dbddbc30f08ca8c6345e2f3b0d196482fa9d2a5d4f4d32", - "system/App/Frontend/ContentCollectionTags.php": "3efbbc93bf1244d0cf198615cdced96a0a3f94c442b926d4fb1b578b62b7a951", + "system/App/Frontend/ContentCollectionTags.php": "337085d73f0ad609b5a37a4fa72537459f4ae2e5f503e39a8750be95127d89b5", "system/App/Frontend/Debug/PublicDebugCollector.php": "6755ad710e9d045b2bfa74745354f717c101236b5d64ad5530fd779468969a00", - "system/App/Frontend/Debug/PublicDebugToolbar.php": "4df82a613b12bde3fe75c07418d2be87bf1b8ce0888cac072de5f3f0a9149710", + "system/App/Frontend/Debug/PublicDebugToolbar.php": "89a0fd4a1cab3ac8e3576cd8116332edbaaf6e09d3e05b0daa5798cd8f59c6ff", "system/App/Frontend/Debug/PublicLayoutInspector.php": "8a7f5ea4cff9834ad13cac7e0d2380d878978e406e71860e1ad9ca183e427dab", "system/App/Frontend/DocumentAccessPolicy.php": "13102e35dada9eb0e174189b976605257b48a32756e801d1ec5c358d0c0912b8", "system/App/Frontend/DocumentApi/Controller.php": "7c5ffecf7e1515710931d7c31b5b2c5f555f8d35778a1fc202a8136b07c2490c", @@ -1160,8 +1227,8 @@ "system/App/Frontend/RequestItemContext.php": "28f70292d7feeb77b88246d1d3299ff54447afc1aa6c525211835a4cfd51ebb4", "system/App/Frontend/RequestItemRenderer.php": "a941b0ec8eb558792499fb0b9ea5b2ff049a59ddb0b61c3f364aa62fe959fd52", "system/App/Frontend/RequestListReadModel.php": "7eedb83e42382cada17e60484108702a52f8a890b59d93cd921e194025af6064", - "system/App/Frontend/RequestListRenderer.php": "03baf9b1df3e0488854ff2e0a2c1eff09bec0610244ac3385b009290d5c90a1d", - "system/App/Frontend/RequestRenderer.php": "a064f09d308adda286400177a39b4d14a4f115f0bf4b1c31e559277ddd84286d", + "system/App/Frontend/RequestListRenderer.php": "b042f3961c9609a24eaf2b012dd62840ad183d938bf6312b32731c236ea07ef1", + "system/App/Frontend/RequestRenderer.php": "99145390cbfe803388786dcf6de60cb4f384224955681e7f855e4b7ef19355b1", "system/App/Frontend/RequestRepository.php": "f0f97117e26da1dd93af7994b7d70ed99c7fd3dd1cd20a88cf799fe1a2b30f49", "system/App/Frontend/RequestSort.php": "8585e125c76a1494aa6c481c9de1ba6849491099927be7cabf75604d37b4403e", "system/App/Frontend/RubricPermissionResolver.php": "1223d6c2839952c98475bf8d802e4c1c9f058be3b2cadabe03fe7bb8cf3973af", @@ -1183,7 +1250,7 @@ "system/App/Helpers/Date.php": "2ed2f352f52316484be9f915c73f90357d89aaf6945a398dcea1bed136c2c454", "system/App/Helpers/Debug.php": "d495a96e844ed9109ec8eb71227e5f02155f61b08b719793d8be16a543f3c106", "system/App/Helpers/Dir.php": "0791ed320ab894e2c768d16768c227e9c68bbc627723eae289f587551037fb66", - "system/App/Helpers/File.php": "26299102f0ac69eb7c47ea4ecf37f9be29487cc66bcb0347deae1eebd6216043", + "system/App/Helpers/File.php": "4a43dacb8964e023a4361f2a3057da33a2b044628a4af3c7bd7e36171c024291", "system/App/Helpers/Hooks.php": "266e65cd317245297652756f2cbfb0ae4af44f7f1249d9009d1c955594994803", "system/App/Helpers/Html.php": "4b4b6387bf40ef4f91fef02ef115ee4b7230694eed93633ec5caa6afb195e013", "system/App/Helpers/Ip.php": "e982ea7edd23850100b31bd4a82fac97bd9dc4615e13a3e559da266d8299b6ba", @@ -1195,7 +1262,7 @@ "system/App/Helpers/Request.php": "29cc2cf20e1e4acd6e2f23c3de9b9606aec3f44d0b3577613b4fde376f7ae858", "system/App/Helpers/Response.php": "beea1596703526b7a2424459e392487e8bc8a9ce216f8d70bd2b09564709e0b9", "system/App/Helpers/RussianStemmer.php": "60a896c8254290ae554273132c103c1c209a14944f52cfd631e7a3ae13145529", - "system/App/Helpers/SearchText.php": "f9fed5c018bf3337dcbcda97ef0b7219f11f13993678e61eb50858403e30dead", + "system/App/Helpers/SearchText.php": "1747521adb640161c6f4df648134c8349769e3cd7ddbc726118af81f1a4dcbf0", "system/App/Helpers/Secure.php": "99a5a170cd574e5b6a6c3b433c5b8bac92407fba62088c93e893e215f18162a3", "system/App/Helpers/SoftDelete.php": "9b85f227bbbc12e4bddd9bcf211b477044041b87bf94d1a422f80d46a10c3ccd", "system/App/Helpers/Str.php": "7eddedb821b476f5284573171899a97ab653b23fc2777cbef780a802a1e9aac2", @@ -1203,12 +1270,12 @@ "system/App/Helpers/Url.php": "418ed3e2682f9097b62d495769864843acc2a9650a90a98d24542acdfdb0946f", "system/App/Helpers/Valid.php": "d5ebfabf53755ae3e66acade5dcaebd9ca0f5f151f988e1fa0a51d3dba9a0983", "system/App/Helpers/Zip.php": "cf34358f6908b026e745bb94e5355b4376d46d31d3dfe78cabbf118c04f745a8", - "system/bootstrap.php": "b65f00c1b32ee9d9ed8df7f7750108216fa1b3a347242822be9838891ccf629c", + "system/bootstrap.php": "2c72cc44f36c9250612b30f8f73efd510b47a5497a71b8a18658627e82e9bb59", "system/distribution.json": "c498272f17ce6f0fbd79401fb264089d6380799a0a102c7f6e5c2fa2149439f7", "system/language/ru/lang.xml": "7d46cc9822f064a1af4d6528ccfe2dd4a9cc318912e327fd807befd2ca3dfd11", "system/preload.php": "66bfb1aaf36ba85945b729b09a37e96ead0be1cc2749ebd9abd4b9da4f25ba0e", "system/public.php": "3f72b5bf44bc0ba5ba6fb6b3adc2e2f9d5e9902d9cdfa3d7b748f4aebd179b87", - "system/release.php": "3f39c44d6c95e376a74272bcd4d91dc3669ab643279f67609de2de6ac93a309d", + "system/release.php": "c91d409134695fbf4d0016568b4ca5db774cec3b924364b604305d49178eb081", "system/vendor/Captcha/fonts/palatino_linotype_bold.png": "38b6a1d4a4bd1d0c056205119c5e24176913ac0d898371f53a0339952a319365", "system/vendor/Captcha/fonts/perpetua_bold.png": "55dbbe97dae128dea47b5e8d640414b3d813119c854c688037a20a581ec8fdc4", "system/vendor/Captcha/fonts/times_bold.png": "d3d32ce1f418e3c3c3b1afe3b6acabadb36207bc12368f5e86420cf1a529ff8f", @@ -1803,7 +1870,9 @@ "README.md": "core", "adminx/.htaccess": "core", "adminx/Support/AdminLocale.php": "core", + "adminx/Support/BulkActionExecutor.php": "core", "adminx/Support/CodeEditor.php": "core", + "adminx/Support/GlobalSearchRegistry.php": "core", "adminx/Support/InterfaceSettings.php": "core", "adminx/Support/ModuleExtensions.php": "core", "adminx/Support/Notifications.php": "core", @@ -1829,10 +1898,14 @@ "adminx/assets/fonts/OpenSans-SemiBold.woff2": "core", "adminx/assets/img/logo.svg": "core", "adminx/assets/js/adminx.js": "core", + "adminx/assets/js/bulk-actions.js": "core", + "adminx/assets/js/command-palette.js": "core", "adminx/assets/js/editor-codemirror.js": "core", "adminx/assets/js/editor-tiptap.js": "core", "adminx/assets/js/media-picker.js": "core", "adminx/assets/js/saved-views.js": "core", + "adminx/assets/src/bulk-actions.js": "core", + "adminx/assets/src/command-palette.js": "core", "adminx/assets/src/editor-tiptap.js": "core", "adminx/assets/vendor/codemirror/addon/dialog/dialog.css": "core", "adminx/assets/vendor/codemirror/addon/dialog/dialog.js": "core", @@ -2041,6 +2114,13 @@ "adminx/modules/Database/migrations/003_document_fields_text_composite_index.php": "core", "adminx/modules/Database/module.php": "core", "adminx/modules/Database/view/index.twig": "core", + "adminx/modules/Directories/Controller.php": "core", + "adminx/modules/Directories/Model.php": "core", + "adminx/modules/Directories/assets/directories.css": "core", + "adminx/modules/Directories/assets/directories.js": "core", + "adminx/modules/Directories/migrations/001_create_directories.sql": "core", + "adminx/modules/Directories/module.php": "core", + "adminx/modules/Directories/view/index.twig": "core", "adminx/modules/Documents/Controller.php": "core", "adminx/modules/Documents/DocumentHookRunner.php": "core", "adminx/modules/Documents/EditConflict.php": "core", @@ -2052,9 +2132,11 @@ "adminx/modules/Documents/language/en/client.xml": "core", "adminx/modules/Documents/language/en/interface.xml": "core", "adminx/modules/Documents/language/en/runtime.xml": "core", + "adminx/modules/Documents/language/en/zz-interface.xml": "core", "adminx/modules/Documents/language/ru/client.xml": "core", "adminx/modules/Documents/language/ru/interface.xml": "core", "adminx/modules/Documents/language/ru/runtime.xml": "core", + "adminx/modules/Documents/language/ru/zz-interface.xml": "core", "adminx/modules/Documents/migrations/001_correct_breadcrumb_title.sql": "core", "adminx/modules/Documents/migrations/002_correct_legacy_breadcrumb_title.sql": "core", "adminx/modules/Documents/migrations/003_create_document_api_tokens.sql": "core", @@ -2072,6 +2154,7 @@ "adminx/modules/Documents/migrations/015_document_creation_presets.sql": "core", "adminx/modules/Documents/migrations/016_reconcile_document_content_columns.php": "core", "adminx/modules/Documents/module.php": "core", + "adminx/modules/Documents/view/_product_panels.twig": "core", "adminx/modules/Documents/view/api.twig": "core", "adminx/modules/Documents/view/edit.twig": "core", "adminx/modules/Documents/view/index.twig": "core", @@ -2090,6 +2173,9 @@ "adminx/modules/Events/migrations/001_materialize_event_logs.sql": "core", "adminx/modules/Events/module.php": "core", "adminx/modules/Events/view/index.twig": "core", + "adminx/modules/GlobalSearch/Controller.php": "core", + "adminx/modules/GlobalSearch/Model.php": "core", + "adminx/modules/GlobalSearch/module.php": "core", "adminx/modules/Groups/Controller.php": "core", "adminx/modules/Groups/Model.php": "core", "adminx/modules/Groups/Setup.php": "core", @@ -2106,6 +2192,7 @@ "adminx/modules/Groups/migrations/003_seed_default_roles.sql": "core", "adminx/modules/Groups/migrations/004_merge_legacy_roles.sql": "core", "adminx/modules/Groups/migrations/005_register_development_site_permission.sql": "core", + "adminx/modules/Groups/migrations/006_normalize_control_panel_permission.sql": "core", "adminx/modules/Groups/module.php": "core", "adminx/modules/Groups/view/list.twig": "core", "adminx/modules/Media/Controller.php": "core", @@ -2148,23 +2235,26 @@ "adminx/modules/Navigation/migrations/001_create_navigation_tables.sql": "core", "adminx/modules/Navigation/module.php": "core", "adminx/modules/Navigation/view/index.twig": "core", - "adminx/modules/Registrations/Controller.php": "core", - "adminx/modules/Registrations/Model.php": "core", - "adminx/modules/Registrations/Schema.php": "core", - "adminx/modules/Registrations/assets/registrations.css": "core", - "adminx/modules/Registrations/assets/registrations.js": "core", - "adminx/modules/Registrations/language/en/client.xml": "core", - "adminx/modules/Registrations/language/en/interface.xml": "core", - "adminx/modules/Registrations/language/en/runtime.xml": "core", - "adminx/modules/Registrations/language/ru/client.xml": "core", - "adminx/modules/Registrations/language/ru/interface.xml": "core", - "adminx/modules/Registrations/language/ru/runtime.xml": "core", - "adminx/modules/Registrations/migrations/001_create_registration_certificates.sql": "core", - "adminx/modules/Registrations/migrations/002_register_registration_permissions.sql": "core", - "adminx/modules/Registrations/migrations/uninstall.sql": "core", - "adminx/modules/Registrations/module.php": "core", - "adminx/modules/Registrations/view/edit.twig": "core", - "adminx/modules/Registrations/view/index.twig": "core", + "adminx/modules/PublicSite/Controller.php": "core", + "adminx/modules/PublicSite/Model.php": "core", + "adminx/modules/PublicSite/PresentationModel.php": "core", + "adminx/modules/PublicSite/PresentationsController.php": "core", + "adminx/modules/PublicSite/assets/presentations.css": "core", + "adminx/modules/PublicSite/assets/presentations.js": "core", + "adminx/modules/PublicSite/assets/public-site.css": "core", + "adminx/modules/PublicSite/language/en/client.xml": "core", + "adminx/modules/PublicSite/language/en/interface.xml": "core", + "adminx/modules/PublicSite/language/ru/client.xml": "core", + "adminx/modules/PublicSite/language/ru/interface.xml": "core", + "adminx/modules/PublicSite/migrations/001_create_presentations.sql": "core", + "adminx/modules/PublicSite/module.php": "core", + "adminx/modules/PublicSite/view/_placement_pagination.twig": "core", + "adminx/modules/PublicSite/view/_tabs.twig": "core", + "adminx/modules/PublicSite/view/diagnostics.twig": "core", + "adminx/modules/PublicSite/view/index.twig": "core", + "adminx/modules/PublicSite/view/map.twig": "core", + "adminx/modules/PublicSite/view/placements.twig": "core", + "adminx/modules/PublicSite/view/presentations.twig": "core", "adminx/modules/Requests/Controller.php": "core", "adminx/modules/Requests/Model.php": "core", "adminx/modules/Requests/assets/requests.css": "core", @@ -2210,6 +2300,8 @@ "adminx/modules/Rubrics/RubricFieldSetLinks.php": "core", "adminx/modules/Rubrics/RubricRevisions.php": "core", "adminx/modules/Rubrics/RubricSchemaImpact.php": "core", + "adminx/modules/Rubrics/assets/field-sets.css": "core", + "adminx/modules/Rubrics/assets/field-sets.js": "core", "adminx/modules/Rubrics/assets/rubrics.css": "core", "adminx/modules/Rubrics/assets/rubrics.js": "core", "adminx/modules/Rubrics/language/en/interface.xml": "core", @@ -2229,7 +2321,11 @@ "adminx/modules/Rubrics/migrations/011_schema_revisions.sql": "core", "adminx/modules/Rubrics/migrations/012_linked_field_sets.sql": "core", "adminx/modules/Rubrics/migrations/013_group_form_conditions.php": "core", + "adminx/modules/Rubrics/migrations/014_rubric_purpose.php": "core", + "adminx/modules/Rubrics/migrations/015_merge_directory_permissions.php": "core", "adminx/modules/Rubrics/module.php": "core", + "adminx/modules/Rubrics/view/_content_tabs.twig": "core", + "adminx/modules/Rubrics/view/field-sets.twig": "core", "adminx/modules/Rubrics/view/index.twig": "core", "adminx/modules/Security/Controller.php": "core", "adminx/modules/Security/assets/security.css": "core", @@ -2333,8 +2429,10 @@ "help/README.md": "core", "help/administration/README.md": "core", "help/administration/console.md": "core", + "help/administration/content-and-system-tools.md": "core", "help/administration/dashboard.md": "core", "help/administration/operations.md": "core", + "help/administration/search-and-activity.md": "core", "help/administration/settings.md": "core", "help/administration/updates.md": "core", "help/administration/users.md": "core", @@ -2345,9 +2443,13 @@ "help/content/README.md": "core", "help/content/blocks.md": "core", "help/content/catalog.md": "core", + "help/content/content-model-tools.md": "core", "help/content/content-studio.md": "core", + "help/content/directories.md": "core", "help/content/documents.md": "core", "help/content/navigation.md": "core", + "help/content/presentations.md": "core", + "help/content/public-site.md": "core", "help/content/requests.md": "core", "help/content/rubrics.md": "core", "help/content/templates.md": "core", @@ -2376,6 +2478,7 @@ "help/en/administration/console.md": "core", "help/en/administration/dashboard.md": "core", "help/en/administration/operations.md": "core", + "help/en/administration/search-and-activity.md": "core", "help/en/administration/settings.md": "core", "help/en/administration/updates.md": "core", "help/en/administration/users.md": "core", @@ -2386,9 +2489,13 @@ "help/en/content/README.md": "core", "help/en/content/blocks.md": "core", "help/en/content/catalog.md": "core", + "help/en/content/content-model-tools.md": "core", "help/en/content/content-studio.md": "core", + "help/en/content/directories.md": "core", "help/en/content/documents.md": "core", "help/en/content/navigation.md": "core", + "help/en/content/presentations.md": "core", + "help/en/content/public-site.md": "core", "help/en/content/requests.md": "core", "help/en/content/rubrics.md": "core", "help/en/content/templates.md": "core", @@ -2459,6 +2566,7 @@ "help/en/modules/antispam.md": "core", "help/en/modules/banners.md": "core", "help/en/modules/benchmark.md": "core", + "help/en/modules/cache-warmup.md": "core", "help/en/modules/comments.md": "core", "help/en/modules/commerce.md": "core", "help/en/modules/commerceml.md": "core", @@ -2478,6 +2586,7 @@ "help/en/modules/interactions.md": "core", "help/en/modules/legacy-migration.md": "core", "help/en/modules/localization.md": "core", + "help/en/modules/mcp.md": "core", "help/en/modules/migrations.md": "core", "help/en/modules/module-php.md": "core", "help/en/modules/not-found.md": "core", @@ -2490,6 +2599,7 @@ "help/en/modules/popups.md": "core", "help/en/modules/ratings.md": "core", "help/en/modules/related.md": "core", + "help/en/modules/reliable-events.md": "core", "help/en/modules/repository.md": "core", "help/en/modules/reviews.md": "core", "help/en/modules/rss.md": "core", @@ -2550,6 +2660,7 @@ "help/modules/antispam.md": "core", "help/modules/banners.md": "core", "help/modules/benchmark.md": "core", + "help/modules/cache-warmup.md": "core", "help/modules/comments.md": "core", "help/modules/commerce.md": "core", "help/modules/commerceml.md": "core", @@ -2569,6 +2680,7 @@ "help/modules/interactions.md": "core", "help/modules/legacy-migration.md": "core", "help/modules/localization.md": "core", + "help/modules/mcp.md": "core", "help/modules/migrations.md": "core", "help/modules/module-php.md": "core", "help/modules/not-found.md": "core", @@ -2581,6 +2693,7 @@ "help/modules/popups.md": "core", "help/modules/ratings.md": "core", "help/modules/related.md": "core", + "help/modules/reliable-events.md": "core", "help/modules/repository.md": "core", "help/modules/reviews.md": "core", "help/modules/rss.md": "core", @@ -2711,7 +2824,10 @@ "system/App/Content/CatalogMenuRepository.php": "core", "system/App/Content/CatalogTables.php": "core", "system/App/Content/ContactsTables.php": "core", + "system/App/Content/ContentPlacementMap.php": "core", "system/App/Content/ContentTables.php": "core", + "system/App/Content/ContentTagDependencies.php": "core", + "system/App/Content/Directories/DirectoryRepository.php": "core", "system/App/Content/DocumentTerms.php": "core", "system/App/Content/Documents/ContentCacheInvalidator.php": "core", "system/App/Content/Documents/DocumentAliasRegistry.php": "core", @@ -2726,11 +2842,13 @@ "system/App/Content/Documents/DocumentMediaFinalizer.php": "core", "system/App/Content/Documents/DocumentMediaPath.php": "core", "system/App/Content/Documents/DocumentMutationService.php": "core", + "system/App/Content/Documents/DocumentPickerRepository.php": "core", "system/App/Content/Documents/DocumentRevisionPayload.php": "core", "system/App/Content/Documents/DocumentRubricCodeRunner.php": "core", "system/App/Content/Documents/DocumentSaveEvent.php": "core", "system/App/Content/Documents/DocumentSaveEvents.php": "core", "system/App/Content/Documents/DocumentSaveRejected.php": "core", + "system/App/Content/Documents/DocumentSearch.php": "core", "system/App/Content/Documents/DocumentSnapshotBuilder.php": "core", "system/App/Content/Documents/DocumentSnapshotRepository.php": "core", "system/App/Content/Documents/DocumentSnapshotStore.php": "core", @@ -2738,6 +2856,7 @@ "system/App/Content/Documents/RubricSchemaBuilder.php": "core", "system/App/Content/ExtensionTables.php": "core", "system/App/Content/Fields/AbstractFieldType.php": "core", + "system/App/Content/Fields/ComputedFieldEvaluator.php": "core", "system/App/Content/Fields/DocumentMediaFieldTrait.php": "core", "system/App/Content/Fields/DocumentMediaFieldType.php": "core", "system/App/Content/Fields/DocumentRelationFieldTrait.php": "core", @@ -2768,6 +2887,7 @@ "system/App/Content/Fields/Types/ChoiceValue.php": "core", "system/App/Content/Fields/Types/Code.php": "core", "system/App/Content/Fields/Types/ColorValue.php": "core", + "system/App/Content/Fields/Types/Computed.php": "core", "system/App/Content/Fields/Types/ContactValue.php": "core", "system/App/Content/Fields/Types/Content.php": "core", "system/App/Content/Fields/Types/Date.php": "core", @@ -2809,10 +2929,23 @@ "system/App/Content/Fields/Types/Teasers.php": "core", "system/App/Content/Fields/Types/TextToImage.php": "core", "system/App/Content/Fields/Types/Youtube.php": "core", + "system/App/Content/Introspection/ContentDiagnosis.php": "core", + "system/App/Content/Introspection/ContractRegistry.php": "core", + "system/App/Content/Introspection/IntrospectionSanitizer.php": "core", + "system/App/Content/Introspection/IntrospectionService.php": "core", + "system/App/Content/Introspection/PageContextInspector.php": "core", "system/App/Content/Packages/ContentPackageWriter.php": "core", "system/App/Content/Packages/PackageDependencyContext.php": "core", "system/App/Content/Packages/PackageModuleProvisioner.php": "core", "system/App/Content/PaymentPrograms.php": "core", + "system/App/Content/Presentation/DocumentPresentationBridge.php": "core", + "system/App/Content/Presentation/PresentationAssignmentRepository.php": "core", + "system/App/Content/Presentation/PresentationDataRegistry.php": "core", + "system/App/Content/Presentation/PresentationPreview.php": "core", + "system/App/Content/Presentation/PresentationRenderer.php": "core", + "system/App/Content/Presentation/PresentationRepository.php": "core", + "system/App/Content/Presentation/PresentationRevisionRepository.php": "core", + "system/App/Content/Presentation/PresentationSyntax.php": "core", "system/App/Content/ProductShipping.php": "core", "system/App/Content/PublicShellTables.php": "core", "system/App/Content/PublicUserTables.php": "core", @@ -2828,6 +2961,7 @@ "system/App/Content/Requests/RequestRuntimeValue.php": "core", "system/App/Content/Requests/RequestTieBreakerAdvisor.php": "core", "system/App/Content/Requests/RequestViewPreview.php": "core", + "system/App/Content/Revisions/JsonRevisionStore.php": "core", "system/App/Content/Rubrics/FieldSetMerge.php": "core", "system/App/Content/Rubrics/FieldSetRegistry.php": "core", "system/App/Content/Rubrics/RubricFieldMutationService.php": "core", diff --git a/README.md b/README.md index 431015d..b50e09b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AVE.cms -**AVE.cms 3.3 build 0.32** — модульная система управления сайтами на PHP. +**AVE.cms 3.3 build 0.35** — модульная система управления сайтами на PHP. Она подходит для проектов, в которых структуру содержимого нужно собирать из собственных рубрик, полей, документов, запросов, блоков и шаблонов, не привязываясь к заранее заданному типу сайта. diff --git a/adminx/Support/BulkActionExecutor.php b/adminx/Support/BulkActionExecutor.php new file mode 100644 index 0000000..6e60346 --- /dev/null +++ b/adminx/Support/BulkActionExecutor.php @@ -0,0 +1,91 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\ErrorReport; + + /** + * Единый безопасный цикл массовых административных действий. + * + * Контроллер по-прежнему отвечает за CSRF, права и аудит, а executor + * нормализует ID, ограничивает размер пачки и изолирует ошибку одной записи. + */ + class BulkActionExecutor + { + public static function execute($action, $rawIds, array $actions, $limit = 200) + { + $action = trim((string) $action); + if ($action === '' || empty($actions[$action]) || !is_callable($actions[$action])) { + throw new \InvalidArgumentException('Неизвестное массовое действие'); + } + + $ids = self::normalizeIds($rawIds, $limit); + $result = array( + 'action' => $action, + 'requested' => count($ids), + 'done' => 0, + 'skipped' => 0, + 'errors' => array(), + ); + + foreach ($ids as $id) { + try { + $outcome = call_user_func($actions[$action], $id); + if ($outcome === false || (is_array($outcome) && isset($outcome['done']) && !$outcome['done'])) { + $result['skipped']++; + continue; + } + + $result['done']++; + } catch (\Throwable $e) { + $result['errors'][] = '#' . $id . ': ' . ErrorReport::publicMessage( + 'Не удалось обработать запись', + $e, + 'BULK', + array('action' => $action, 'id' => $id) + ); + } + } + + return $result; + } + + public static function normalizeIds($rawIds, $limit = 200) + { + $limit = max(1, min(1000, (int) $limit)); + if (is_string($rawIds)) { + $rawIds = trim($rawIds); + $decoded = $rawIds !== '' && $rawIds[0] === '[' ? json_decode($rawIds, true) : null; + $rawIds = is_array($decoded) ? $decoded : preg_split('/[\s,;]+/', $rawIds, -1, PREG_SPLIT_NO_EMPTY); + } + + $ids = array(); + foreach (is_array($rawIds) ? $rawIds : array() as $rawId) { + $id = (int) $rawId; + if ($id > 0) { + $ids[$id] = $id; + } + } + + $ids = array_values($ids); + if (!$ids || count($ids) > $limit) { + throw new \InvalidArgumentException('Выберите от 1 до ' . $limit . ' записей'); + } + + return $ids; + } + } diff --git a/adminx/Support/GlobalSearchRegistry.php b/adminx/Support/GlobalSearchRegistry.php new file mode 100644 index 0000000..3e1bd51 --- /dev/null +++ b/adminx/Support/GlobalSearchRegistry.php @@ -0,0 +1,188 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Permission; + + /** + * Реестр провайдеров административного поиска. + * + * Provider получает ($query, $limit) и возвращает список безопасных + * admin-relative результатов. Ошибка одного provider не ломает палитру. + */ + class GlobalSearchRegistry + { + protected static $providers = array(); + protected static $reported = array(); + + public static function register($code, array $definition) + { + $code = self::code($code); + $provider = isset($definition['provider']) ? $definition['provider'] : null; + if ($code === '' || !is_callable($provider)) { + return false; + } + + self::$providers[$code] = array( + 'code' => $code, + 'provider' => $provider, + 'permission' => isset($definition['permission']) ? trim((string) $definition['permission']) : '', + 'priority' => isset($definition['priority']) ? (int) $definition['priority'] : 100, + 'limit' => max(1, min(30, isset($definition['limit']) ? (int) $definition['limit'] : 8)), + ); + return true; + } + + public static function search($query, $limit = 30) + { + $query = trim(preg_replace('/\s+/u', ' ', (string) $query)); + $limit = max(1, min(50, (int) $limit)); + if (mb_strlen($query, 'UTF-8') < 2) { + return array(); + } + + $providers = array_values(self::$providers); + usort($providers, function ($left, $right) { + $order = (int) $left['priority'] <=> (int) $right['priority']; + return $order !== 0 ? $order : strnatcasecmp((string) $left['code'], (string) $right['code']); + }); + + $items = array(); + foreach ($providers as $providerOrder => $definition) { + if ($definition['permission'] !== '' && !Permission::check($definition['permission'])) { + continue; + } + + try { + $rows = call_user_func( + $definition['provider'], + $query, + min($definition['limit'], $limit) + ); + } catch (\Throwable $e) { + self::report($definition['code'], $e); + continue; + } + + $position = 0; + foreach (is_array($rows) ? $rows : array() as $row) { + $item = self::normalize($row, $definition['code']); + if (!$item) { + continue; + } + + $item['_provider_order'] = $providerOrder; + $item['_position'] = $position++; + $items[] = $item; + } + } + + usort($items, function ($left, $right) { + $provider = (int) $left['_provider_order'] <=> (int) $right['_provider_order']; + if ($provider !== 0) { + return $provider; + } + + $score = (float) $right['score'] <=> (float) $left['score']; + return $score !== 0 ? $score : ((int) $left['_position'] <=> (int) $right['_position']); + }); + + $items = array_slice($items, 0, $limit); + foreach ($items as &$item) { + unset($item['_provider_order'], $item['_position'], $item['score']); + } + + unset($item); + return $items; + } + + public static function providers() + { + return array_keys(self::$providers); + } + + public static function resetRuntime() + { + self::$providers = array(); + self::$reported = array(); + } + + protected static function normalize($row, $providerCode) + { + if (!is_array($row)) { + return null; + } + + $title = self::text(isset($row['title']) ? $row['title'] : '', 190); + $url = self::localUrl(isset($row['url']) ? $row['url'] : ''); + if ($title === '' || $url === '') { + return null; + } + + $icon = trim((string) (isset($row['icon']) ? $row['icon'] : 'ti ti-search')); + if (!preg_match('/^ti(?:\s+ti-[a-z0-9-]+)+$/', $icon)) { + $icon = 'ti ti-search'; + } + + return array( + 'type' => self::code(isset($row['type']) ? $row['type'] : $providerCode), + 'group' => self::text(isset($row['group']) ? $row['group'] : 'Результаты', 80), + 'title' => $title, + 'subtitle' => self::text(isset($row['subtitle']) ? $row['subtitle'] : '', 300), + 'url' => $url, + 'icon' => $icon, + 'score' => isset($row['score']) && is_numeric($row['score']) ? (float) $row['score'] : 0.0, + ); + } + + protected static function localUrl($url) + { + $url = trim((string) $url); + if ($url === '' + || $url[0] !== '/' + || strpos($url, '//') === 0 + || strpos($url, '\\') !== false + || preg_match('/[\r\n\x00-\x1f]/', $url)) { + return ''; + } + + return substr($url, 0, 1000); + } + + protected static function code($value) + { + $value = strtolower(trim((string) $value)); + return substr(preg_replace('/[^a-z0-9_.-]+/', '_', $value), 0, 64); + } + + protected static function text($value, $limit) + { + $value = html_entity_decode(strip_tags((string) $value), ENT_QUOTES, 'UTF-8'); + $value = trim(preg_replace('/\s+/u', ' ', $value)); + return mb_substr($value, 0, (int) $limit, 'UTF-8'); + } + + protected static function report($code, \Throwable $e) + { + if (isset(self::$reported[$code])) { + return; + } + + self::$reported[$code] = true; + error_log('Global search provider ' . $code . ': ' . $e->getMessage()); + } + } diff --git a/adminx/Support/InterfaceSettings.php b/adminx/Support/InterfaceSettings.php index f68a3af..ee395d7 100644 --- a/adminx/Support/InterfaceSettings.php +++ b/adminx/Support/InterfaceSettings.php @@ -133,6 +133,10 @@ return strcmp((string) $a['layout_code'], (string) $b['layout_code']); }); + if ($scope === 'navigation' && $config) { + $items = self::mergeNewNavigationItems($items); + } + foreach ($items as &$item) { unset($item['_layout_position']); } @@ -148,6 +152,72 @@ })); } + /** + * Новые пункты, которых ещё нет в сохранённой раскладке, остаются внутри + * своей группы по штатному sort_order, а не уходят в конец всего меню. + */ + protected static function mergeNewNavigationItems(array $items) + { + $configured = array(); + $added = array(); + foreach ($items as $item) { + if ($item['_layout_position'] === null) { + $added[] = $item; + } else { + $configured[] = $item; + } + } + + usort($added, array(__CLASS__, 'compareDefaultOrder')); + foreach ($added as $item) { + $insertAt = count($configured); + $lastPeer = null; + foreach ($configured as $index => $peer) { + if (!self::navigationPeers($item, $peer)) { + continue; + } + + $lastPeer = $index; + if (self::compareDefaultOrder($item, $peer) < 0) { + $insertAt = $index; + break; + } + + $insertAt = $index + 1; + } + + if ($lastPeer === null) { + $insertAt = count($configured); + } + + array_splice($configured, $insertAt, 0, array($item)); + } + + return $configured; + } + + protected static function navigationPeers(array $left, array $right) + { + $leftParent = isset($left['parent']) ? (string) $left['parent'] : ''; + $rightParent = isset($right['parent']) ? (string) $right['parent'] : ''; + if ($leftParent !== '' || $rightParent !== '') { + return $leftParent !== '' && $leftParent === $rightParent; + } + + return (string) $left['group'] === (string) $right['group']; + } + + protected static function compareDefaultOrder(array $left, array $right) + { + $leftOrder = isset($left['sort_order']) ? (int) $left['sort_order'] : 1000; + $rightOrder = isset($right['sort_order']) ? (int) $right['sort_order'] : 1000; + if ($leftOrder !== $rightOrder) { + return $leftOrder < $rightOrder ? -1 : 1; + } + + return strcmp((string) $left['layout_code'], (string) $right['layout_code']); + } + protected static function navigationHierarchy(array $items) { $roots = array(); diff --git a/adminx/Support/ModuleExtensions.php b/adminx/Support/ModuleExtensions.php index 46da5a6..5f69fc7 100644 --- a/adminx/Support/ModuleExtensions.php +++ b/adminx/Support/ModuleExtensions.php @@ -51,6 +51,7 @@ ); if (!empty($module['installed']) && !empty($module['enabled'])) { self::registerMenu($module['admin_extension']); + self::registerSearch($module['code'], $module['admin_extension']); } } @@ -168,6 +169,64 @@ return $items; } + /** + * Missing hard and recommended dependencies for the module owning a route. + */ + public static function dependencyNotices($path) + { + $module = self::moduleForPath($path); + if (!$module) { + return array(); + } + + $descriptor = ModuleManager::descriptor($module['code']); + $required = is_array($descriptor) && isset($descriptor['requires']) + ? self::dependencyDefinitions($descriptor['requires']) + : array(); + $recommended = is_array($descriptor) && isset($descriptor['recommends']) + ? self::dependencyDefinitions($descriptor['recommends']) + : array(); + $requiredCodes = array(); + $recommendedReasons = array(); + foreach ($required as $definition) { + $requiredCodes[] = $definition['code']; + } + + foreach ($recommended as $definition) { + $recommendedReasons[$definition['code']] = $definition['reason']; + } + + $notices = array(); + foreach (array( + 'required' => $requiredCodes, + 'recommended' => array_keys($recommendedReasons), + ) as $level => $codes) { + foreach ($codes as $code) { + $dependency = ModuleManager::get($code); + if ($dependency && !empty($dependency['installed']) && !empty($dependency['enabled'])) { + continue; + } + + $name = $dependency && !empty($dependency['name']) ? (string) $dependency['name'] : (string) $code; + $state = !$dependency ? 'missing' : (empty($dependency['installed']) ? 'available' : 'disabled'); + $reason = $level === 'recommended' && isset($recommendedReasons[$code]) + ? $recommendedReasons[$code] + : ''; + $notices[] = array( + 'level' => $level, + 'code' => (string) $code, + 'name' => $name, + 'state' => $state, + 'reason' => $reason, + 'action' => $state === 'disabled' ? 'Включить модуль' : ($state === 'missing' ? 'Найти в каталоге' : 'Установить модуль'), + 'url' => '/modules?' . ($state === 'missing' ? 'tab=catalog&' : '') . 'focus=' . rawurlencode((string) $code), + ); + } + } + + return $notices; + } + protected static function contributions($type, array $visibleCodes = null) { self::boot(); @@ -250,6 +309,24 @@ } } + protected static function registerSearch($moduleCode, array $config) + { + if (empty($config['search'])) { + return; + } + + foreach (self::normalizeItems($config['search']) as $index => $definition) { + if (!is_array($definition)) { + continue; + } + + $code = isset($definition['code']) && trim((string) $definition['code']) !== '' + ? (string) $definition['code'] + : (string) ((int) $index + 1); + GlobalSearchRegistry::register($moduleCode . '.' . $code, $definition); + } + } + protected static function normalizeItems($items) { if (!is_array($items)) { @@ -285,6 +362,49 @@ return $fallback; } + protected static function moduleForPath($path) + { + $path = '/' . trim((string) $path, '/'); + $match = null; + $matchLength = -1; + foreach (ModuleManager::all() as $module) { + $extension = isset($module['admin_extension']) && is_array($module['admin_extension']) + ? $module['admin_extension'] + : array(); + $url = isset($extension['url']) ? '/' . trim((string) $extension['url'], '/') : ''; + if ($url === '' || ($path !== $url && strpos($path, $url . '/') !== 0) || strlen($url) <= $matchLength) { + continue; + } + + $match = $module; + $matchLength = strlen($url); + } + + return $match; + } + + protected static function dependencyDefinitions($dependencies) + { + $result = array(); + foreach (is_array($dependencies) ? $dependencies : array() as $dependency) { + $code = is_array($dependency) && isset($dependency['code']) + ? strtolower(trim((string) $dependency['code'])) + : strtolower(trim((string) $dependency)); + if ($code === '') { + continue; + } + + $result[] = array( + 'code' => $code, + 'reason' => is_array($dependency) && isset($dependency['reason']) + ? trim((string) $dependency['reason']) + : '', + ); + } + + return $result; + } + /** Управляемая администратором видимость UI-вклада модуля. */ protected static function presentationEnabled($code, $type) { diff --git a/adminx/Support/SectionHelp.php b/adminx/Support/SectionHelp.php index 95796fe..96dcf46 100644 --- a/adminx/Support/SectionHelp.php +++ b/adminx/Support/SectionHelp.php @@ -80,6 +80,10 @@ self::item('#^/documents/views$#', 'Статистика просмотров', 'Показывает посещаемость документов по дням и помогает находить наиболее востребованные страницы.', array('Период влияет только на подневную статистику.', 'Общий счётчик документа хранится отдельно и не очищается вместе с дневными данными.', 'Очистка статистики необратима и предназначена для обслуживания.'), 'Для бизнес-аналитики используйте внешнюю систему аналитики; здесь хранится внутренняя статистика CMS.'), self::item('#^/documents/(?:create|\d+/edit)$#', 'Редактор документа', 'Создаёт и изменяет страницу сайта: её адрес, публикацию, SEO и значения полей выбранной рубрики.', array('Набор полей и их группы задаются рубрикой.', 'Изменение alias может автоматически создать редирект со старого URL.', 'Ctrl+S сохраняет изменения и оставляет редактор открытым; ревизии позволяют вернуться к прошлой версии.'), 'Смена рубрики существующего документа запрещена без отдельной миграции полей.'), self::item('#^/documents#', 'Документы', 'Основной реестр страниц и товарных карточек сайта, построенных на рубриках и их полях.', array('Фильтруйте документы по рубрике, состоянию и названию.', 'Публикацию, копирование и удаление можно выполнять из списка или пакетно.', 'Удалённый документ сначала попадает в корзину и только затем может быть удалён окончательно.'), 'Документ №1 и системная страница 404 защищены от окончательного удаления.', 'content/documents'), + self::item('#^/public-site/presentations$#', 'Представления', 'Создаёт повторно используемое оформление карточек, списков, слайдеров и других результатов.', array('Черновик проверяется на реальном материале и не меняет публичный сайт.', 'Публикация фиксирует версию, а назначение выбирает место и режим применения.', 'Представление меняет внешний вид готовых данных, но не заменяет рубрику, запрос, блок или модуль.'), 'Для совместимости каждое новое назначение начинает работу в режиме «Текущий вывод».', 'content/public-site'), + self::item('#^/public-site/map$#', 'Визуальная карта сайта', 'Показывает деревья меню, связанные страницы и опубликованные документы вне навигации.', array('Уровни строятся из действующих пунктов навигации.', 'Ссылка документа открывает его штатный редактор.', 'Документ вне меню может использоваться запросом, каталогом или прямой ссылкой.'), 'Карта является обзором и ничего не публикует.', 'content/public-site'), + self::item('#^/public-site/placements$#', 'Размещения компонентов', 'Показывает, в каких сохранённых шаблонах используются блоки, подборки, навигации и модульные теги.', array('Вкладка «Размещения» показывает каждое найденное место отдельно.', 'Вкладка «Где используется» объединяет одинаковые компоненты и показывает число связей.', 'Кнопки справа открывают конкретный исходный шаблон и конкретный подключённый компонент.'), 'Карта предназначена для поиска связей и ничего не меняет на публичном сайте.', 'content/public-site'), + self::item('#^/public-site(?:/.*)?$#', 'Публичный сайт', 'Показывает, из каких базовых компонентов собирается сайт и где используется каждый из них.', array('Раздел является картой и не дублирует редакторы шаблонов, рубрик, запросов, блоков и меню.', 'Представления — дополнительный способ оформить уже подготовленные данные.', 'Диагностика сообщает только о явных пробелах в обязательных настройках.'), 'Начните со вкладки «Структура», а содержимое меняйте в штатном редакторе нужного компонента.', 'content/public-site'), self::item('#^/catalog/products/shipping$#', 'Доставка и упаковка', 'Показывает готовность товарных данных к расчёту доставки и позволяет редактировать грузовые места без открытия документа.', array('Каждое грузовое место хранит количество, вес и три габарита.', 'Расчёт включается отдельно и использует только полностью заполненную упаковку.', 'Фильтр помогает быстро найти товары без размеров или с выключенным расчётом.'), 'Вес и размеры указываются для одного грузового места; количество умножает итоговые показатели.'), self::item('#^/catalog/products(?:/.*)?$#', 'Товары каталога', 'Показывает товарные документы в быстром индексе каталога: цены, остатки, изображения и категории.', array('Переиндексация обновляет проекцию товара из полей документа.', 'Редактор товара остаётся редактором документа, но открывается из каталога.', 'Фильтры списка не меняют данные товара.'), 'После изменения каталоговых полей индекс обычно обновляется автоматически.'), @@ -90,6 +94,8 @@ self::item('#^/catalog/\d+/\d+$#', 'Конструктор каталога', 'Связывает поле типа catalog с деревом разделов, документами, полями и публичными фильтрами.', array('Разделы задают структуру каталога и набор доступных полей.', 'Условия фильтров определяют SQL-ограничения публичной выборки.', 'Порядок полей и фильтров меняется перетаскиванием и сохраняется сразу.'), 'Изменения структуры влияют на редактор документов и публичный каталог.'), self::item('#^/catalog#', 'Каталог', 'Управляет структурами каталогов, товарным индексом, фильтрами и связью с документами.', array('Каждый каталог создаётся для конкретного поля рубрики типа catalog.', 'Товары являются документами и не дублируются в отдельной основной таблице.', 'Для вариантов одного товара используются группы вариантов.'), 'Начинайте с выбора каталога, затем настраивайте разделы, поля и фильтры.', 'content/catalog'), + self::item('#^/rubrics/field-sets$#', 'Библиотека наборов полей', 'Добавляет в рубрику готовую структуру полей для типовой задачи.', array('Копия создаёт независимые поля.', 'Связанный режим позволяет позже синхронизировать набор вручную.', 'Перед применением проверяются существующие alias.'), 'После применения настройте порядок и ширину полей в конструкторе рубрики.', 'content/content-model-tools'), + self::item('#^/directories(?:/.*)?$#', 'Справочники', 'Управляет повторно используемыми значениями через обычные рубрики и документы.', array('Сначала создайте справочник и его поля.', 'Затем наполните его документами-значениями.', 'В рабочей рубрике выберите справочник источником поля связи.'), 'Справочник использует штатную модель документов и не создаёт параллельное хранилище.', 'content/content-model-tools'), self::item('#^/rubrics(?:/.*)?$#', 'Рубрики и поля', 'Определяет типы документов: набор полей, группы, шаблоны вывода, права и код жизненного цикла.', array('Поля являются отдельными сущностями со своим типом, настройками и шаблоном.', 'Группы организуют длинные формы документов и не влияют на публичный URL.', 'Код до/после сохранения выполняется при изменении документа и требует проверки синтаксиса.'), 'Удаляйте рубрики и поля только после проверки связанных документов и шаблонов.', 'content/rubrics'), self::item('#^/themes#', 'Темы публичного сайта', 'Управляет файлами оформления, подключениями CSS/JavaScript и способом сборки публичной страницы.', array('Файлы темы находятся в templates/<код темы> и проходят проверку пути и расширения.', 'Во вкладке «Настройки» выбирается нативная сборка AVE.cms или Twig-оболочка темы.', 'Текстовые файлы получают ревизии; тему можно экспортировать или импортировать безопасным ZIP-пакетом.'), 'Ассеты и переопределения темы работают в обоих режимах сборки.', 'content/themes'), self::item('#^/templates#', 'Шаблоны страниц', 'Хранит внешнюю HTML/PHP-оболочку сайта, в которую подставляется содержимое документа и системные теги.', array('Шаблон №1 является системным и не удаляется.', 'Сохранение обновляет БД, файловый кеш и создаёт ревизию.', 'Палитра тегов вставляет зарегистрированные блоки, навигации и значения страницы.'), 'Ошибка PHP в основном шаблоне может нарушить весь публичный сайт — используйте проверку перед сохранением.', 'content/templates'), @@ -113,7 +119,7 @@ self::item('#^/settings/security$#', 'Подтверждение критических действий', 'Управляет повторным запросом пароля перед изменением исполняемого кода и установкой модулей.', array('Подтверждение действует пять минут для текущего пользователя и IP-адреса.', 'Отключение повторного пароля не отменяет проверку прав роли и CSRF-токена.', 'Настройка распространяется на блоки, шаблоны, рубрики, запросы, навигацию и другие операции с кодом.'), 'По умолчанию подтверждение выключено; на рабочем сайте его можно включить как дополнительный защитный слой.'), self::item('#^/settings/constants$#', 'Системные константы', 'Хранит типизированные значения конфигурации, доступные ядру и публичному сайту.', array('Тип определяет допустимый редактор и формат сохранённого значения.', 'Перед добавлением проверьте, нет ли уже настройки с тем же назначением.', 'Удаляйте константу только после поиска её использования в коде и шаблонах.'), 'Название константы является программным контрактом и не должно меняться без миграции.'), self::item('#^/settings/paginations$#', 'Шаблоны пагинации', 'Настраивает HTML постраничной навигации для документов и сохранённых запросов.', array('Базовый шаблон защищён от удаления.', 'Теги страниц, текущего состояния и ссылок подставляются во время рендера.', 'После изменения проверьте первую, среднюю и последнюю страницы списка.'), 'Шаблон должен сохранять доступность ссылок и понятное обозначение текущей страницы.'), - self::item('#^/settings/maintenance$#', 'Обслуживание', 'Очищает адресные кеши и накопленные служебные данные AVE.cms.', array('Каждая кнопка работает только со своим источником данных.', 'Очистка кеша не удаляет документы и настройки.', 'Ревизии и статистика просмотров удаляются необратимо.'), 'Перед очисткой данных, которые нельзя восстановить из документов, создайте резервную копию.'), + self::item('#^/settings/maintenance$#', 'Обслуживание', 'Очищает адресные кеши и накопленные служебные данные AVE.cms.', array('Каждая кнопка работает только со своим источником данных.', 'Очистка кеша не удаляет документы и настройки.', 'Очистка ревизий удаляет сохранённые версии документов, блоков, шаблонов, рубрик, тем, представлений, карточек товаров и фильтров каталога.', 'Аудит действий, история заказов и обращения этой операцией не затрагиваются.'), 'Ревизии и статистика просмотров удаляются необратимо — перед очисткой создайте резервную копию.'), self::item('#^/settings/diagnostics$#', 'Диагностика системы', 'Проверяет PHP, расширения, доступ к каталогам, почту, данные модулей и готовность публичной части.', array('Проверки не отправляют письма и не выполняют внешние операции.', 'Красные состояния нужно устранить до публикации.', 'Тяжёлые проверки могут кратковременно кешироваться.'), 'Диагностика ядра не заменяет отдельную проверку подключённых модулей и внешних сервисов.'), self::item('#^/settings/files$#', 'Системные файлы', 'Даёт контролируемый редактор разрешённых PHP-файлов конфигурации и пользовательских функций.', array('Список файлов ограничен системой и не является файловым менеджером.', 'Перед сохранением выполняется проверка PHP-синтаксиса.', 'Ошибка в таком файле может повлиять и на панель, и на публичный сайт.'), 'Используйте этот раздел для небольших проектных дополнений; крупную функциональность оформляйте модулем.'), self::item('#^/settings/benchmark$#', 'Производительность', 'Измеряет скорость PHP, базы данных, файловых операций и кеша на текущем сервере.', array('Результаты нужны для сравнения одного окружения во времени, а не разных проектов.', 'Повторяйте тест после обновления PHP, БД или инфраструктуры.', 'Тест создаёт кратковременную нагрузку и сохраняет историю запусков.'), 'Оценивайте не только общий балл, но и конкретный медленный этап.'), diff --git a/adminx/assets/css/adminkit.css b/adminx/assets/css/adminkit.css index 71a85c6..e7e6b82 100644 --- a/adminx/assets/css/adminkit.css +++ b/adminx/assets/css/adminkit.css @@ -520,7 +520,6 @@ hr { background: var(--color-danger); border-radius: 999px; border: 2px solid var(--background-page); - font-variant-numeric: tabular-nums; } .notif-menu { width: 330px; @@ -579,7 +578,6 @@ hr { font-weight: 700; font-size: 13px; color: var(--text-secondary); - font-variant-numeric: tabular-nums; } .notif-empty { padding: 30px 16px; @@ -594,6 +592,13 @@ hr { color: var(--color-success); } /* Заголовок в меню пользователя */ +#userMenu > .dropdown-menu { + width: 280px; + max-width: calc(100vw - 16px); +} +#userMenu #themeToggle [data-theme-label] { + white-space: nowrap; +} .dd-user { display: flex; align-items: center; @@ -1805,7 +1810,6 @@ hr { } .ax-list-head > .badge { flex: 0 0 auto; - font-variant-numeric: tabular-nums; } @media (max-width: 680px) { .ax-list-head { @@ -2652,6 +2656,132 @@ input[type="color"].color-input { right: auto; } } +.command-palette { + position: fixed; + z-index: 1300; + inset: 0; +} +.command-palette-backdrop { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.42); + backdrop-filter: blur(2px); +} +.command-palette-dialog { + position: relative; + width: min(680px, calc(100vw - 32px)); + max-height: min(620px, calc(100vh - 96px)); + margin: 72px auto 0; + overflow: hidden; + border-radius: 8px; + background: var(--background-surface, #fff); + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28), 0 4px 18px rgba(15, 23, 42, 0.14); +} +.command-palette-input { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); + min-height: 62px; + padding: 0 var(--space-3) 0 var(--space-5); + border-bottom: 1px solid var(--border-default); + background: inherit; +} +.command-palette-input > .ti { + color: var(--text-secondary); + font-size: 20px; +} +.command-palette-input input { + width: 100%; + border: 0; + outline: 0; + background: transparent; + color: var(--text-primary); + font: inherit; + font-size: 16px; +} +.command-palette-input input:focus, +.command-palette-input input:focus-visible { + outline: 0; + box-shadow: none; +} +.command-palette-input input::-webkit-search-cancel-button { + display: none; + -webkit-appearance: none; + appearance: none; +} +.command-palette-results { + max-height: min(550px, calc(100vh - 160px)); + overflow-y: auto; + padding: var(--space-2); + background: inherit; +} +.command-palette-group { + padding: var(--space-2) var(--space-3) var(--space-1); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} +.command-palette-item { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); + width: 100%; + min-height: 52px; + padding: var(--space-2) var(--space-3); + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-primary); + text-align: left; + cursor: pointer; +} +.command-palette-item:hover, +.command-palette-item.is-active { + background: var(--blue-50); + color: var(--blue-700); +} +.command-palette-item .icon-tile { + width: 36px; + height: 36px; +} +.command-palette-copy { + display: grid; + gap: 2px; + min-width: 0; +} +.command-palette-copy b, +.command-palette-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.command-palette-copy small { + color: var(--text-secondary); +} +.command-palette-empty { + display: grid; + place-items: center; + gap: var(--space-2); + min-height: 180px; + color: var(--text-secondary); + text-align: center; +} +.command-palette-empty .ti { + font-size: 28px; +} +body.command-palette-open { + overflow: hidden; +} +@media (max-width: 720px) { + .command-palette-dialog { + width: calc(100vw - 16px); + max-height: calc(100vh - 24px); + margin-top: 12px; + } +} /* ============================================================ 9. ТАБЛИЦЫ ============================================================ */ @@ -2863,7 +2993,6 @@ table.table { cursor: pointer; display: inline-grid; place-items: center; - font-variant-numeric: tabular-nums; transition: border-color 0.12s, color 0.12s, background-color 0.12s; } .pagination .page:hover, @@ -3044,6 +3173,14 @@ table.table { .modal-footer .mf-left { margin-right: auto; } +.confirm-modal-body { + padding-top: var(--space-4); + padding-bottom: var(--space-5); +} +.confirm-modal-body p { + margin: 0; + line-height: 1.55; +} .dialog-icon { width: 42px; height: 42px; @@ -3571,6 +3708,34 @@ table.table { [data-theme="dark"] .alert-integration { color: #67e8f9; } +.module-dependency-notices { + display: grid; + gap: 8px; + margin-bottom: 16px; +} +.module-dependency-notices .alert { + align-items: center; +} +.module-dependency-copy { + display: grid; + gap: 2px; + min-width: 0; +} +.module-dependency-notices .btn { + margin-left: auto; + flex: 0 0 auto; +} +@media (max-width: 640px) { + .module-dependency-notices .alert { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + } + .module-dependency-notices .btn { + grid-column: 1/-1; + width: 100%; + margin-left: 0; + } +} .banner { display: flex; align-items: center; @@ -4965,8 +5130,8 @@ ul.sortable { } .media-picker-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); + gap: 10px; grid-auto-rows: max-content; flex: 1 1 auto; align-content: start; @@ -4975,7 +5140,7 @@ ul.sortable { } .media-picker-item { display: grid; - grid-template-rows: auto minmax(62px, auto); + grid-template-rows: auto minmax(54px, auto); gap: 0; min-width: 0; padding: 0; @@ -5025,7 +5190,7 @@ ul.sortable { align-content: center; gap: 4px; min-width: 0; - padding: 10px 11px; + padding: 8px 9px; background: var(--background-card); } .media-picker-item b, @@ -5072,7 +5237,7 @@ ul.sortable { } .media-picker-item.is-folder { grid-template-columns: none; - grid-template-rows: auto minmax(62px, auto); + grid-template-rows: auto minmax(54px, auto); align-items: stretch; gap: 0; min-height: 0; @@ -5080,7 +5245,7 @@ ul.sortable { } .media-picker-item.is-folder .media-picker-meta { align-content: center; - padding: 10px 11px; + padding: 8px 9px; background: var(--background-card); } .media-picker-folder-icon { @@ -5102,6 +5267,28 @@ ul.sortable { font-size: 12px; color: var(--text-secondary); } +@media (max-width: 640px) { + .media-picker { + width: 100%; + height: 94vh; + max-height: 94vh; + } + .media-picker-tools { + flex-wrap: wrap; + } + .media-picker-tools .media-picker-search { + flex-basis: calc(100% - 50px); + } + .media-picker-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .media-picker .modal-footer { + padding: 12px 14px; + } + .media-picker-count { + flex-basis: 100%; + } +} /* ============================================================ 14. БОЛЬШИЕ ФОРМЫ / FORM BUILDER / CMS / ПРОЧЕЕ ============================================================ */ @@ -6422,7 +6609,6 @@ body.sidebar-collapsed .sidebar .nav-sub.flyout-open .nav-item .nav-count { font-size: 20px; font-weight: 800; font-family: var(--font-display, inherit); - font-variant-numeric: tabular-nums; line-height: 1.1; } .stat-label { diff --git a/adminx/assets/js/adminx.js b/adminx/assets/js/adminx.js index 318b6cb..f332fe0 100644 --- a/adminx/assets/js/adminx.js +++ b/adminx/assets/js/adminx.js @@ -591,7 +591,7 @@ overlay.className = 'overlay'; var modal = document.createElement('div'); - modal.className = 'modal'; + modal.className = 'modal confirm-modal'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); @@ -600,10 +600,10 @@ '' + '
' + '

' + esc(Adminx.tr(cfg.title || Adminx.t('confirm_title', 'Подтвердите действие'))) + '

' + - (cfg.message ? '

' + esc(Adminx.tr(cfg.message)) + '

' : '') + '
' + '' + '' + + (cfg.message ? '' : '') + ' -
AVE.cms · /adminx
+
AVE.cms · Панель управления
{% endblock %} diff --git a/adminx/modules/Blocks/Model.php b/adminx/modules/Blocks/Model.php index 1bf9f38..aeb060c 100644 --- a/adminx/modules/Blocks/Model.php +++ b/adminx/modules/Blocks/Model.php @@ -285,6 +285,16 @@ return false; } + $dependencies = \App\Content\ContentTagDependencies::block( + (int) $id, + isset($raw['sysblock_alias']) ? (string) $raw['sysblock_alias'] : '' + ); + if ($dependencies) { + throw new \RuntimeException( + 'Блок используется: ' . implode(', ', $dependencies) . '. Сначала уберите эти вызовы.' + ); + } + Revisions::capture((int) $id, 'delete', (int) $authorId, 'Удаление блока', $raw); $row = self::row($raw); if (!$row) { diff --git a/adminx/modules/Blocks/Revisions.php b/adminx/modules/Blocks/Revisions.php index 7789f0c..0138208 100644 --- a/adminx/modules/Blocks/Revisions.php +++ b/adminx/modules/Blocks/Revisions.php @@ -17,12 +17,13 @@ defined('BASEPATH') || die('Direct access to this location is not allowed.'); use DB; - use App\Common\SystemTables; use App\Content\ContentTables; - use App\Helpers\Json; + use App\Content\Revisions\JsonRevisionStore; class Revisions { + protected static $store; + public static function table() { return ContentTables::table('sysblock_revisions'); @@ -43,14 +44,8 @@ public static function listForBlock($blockId, $limit = 50) { - $rows = DB::query( - 'SELECT * FROM ' . self::table() . ' WHERE block_id = %i ORDER BY created_at DESC, id DESC LIMIT %i', - (int) $blockId, - max(1, min(200, (int) $limit)) - )->getAll(); - $out = array(); - foreach ($rows as $row) { + foreach (self::store()->listing($blockId, $limit) as $row) { $out[] = self::format($row, false); } @@ -59,7 +54,7 @@ public static function one($id) { - $row = DB::query('SELECT * FROM ' . self::table() . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc(); + $row = self::store()->one($id); return $row ? self::format($row, true) : null; } @@ -75,38 +70,19 @@ } $snapshot = Model::snapshot($snapshot); - $json = Json::encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - if ($json === false) { - $json = '{}'; - } - - $snapshotHash = sha1($json); $textHash = sha1((string) $snapshot['sysblock_text']); - - if (in_array($action, array('update', 'import', 'restore'), true)) { - $last = DB::query( - 'SELECT snapshot_hash FROM ' . self::table() . ' WHERE block_id = %i ORDER BY created_at DESC, id DESC LIMIT 1', - $blockId - )->getValue(); - if ($last && (string) $last === $snapshotHash) { - return 0; - } - } - - DB::Insert(self::table(), array( - 'block_id' => $blockId, - 'action' => (string) $action, - 'snapshot_hash' => $snapshotHash, + return self::store()->capture( + $blockId, + $action, + $snapshot, + $authorId, + $comment, + array( 'text_hash' => $textHash, - 'snapshot_json' => $json, - 'comment' => trim((string) $comment), - 'author_id' => (int) $authorId, - 'author_name' => self::authorName((int) $authorId), 'source_revision_id' => (int) $sourceRevisionId, - 'created_at' => time(), - )); - - return (int) DB::insertId(); + ), + in_array($action, array('update', 'import', 'restore'), true) + ); } public static function captureCurrent($action, $authorId = 0, $comment = '') @@ -144,98 +120,47 @@ public static function delete($revisionId) { - $revision = self::one($revisionId); - if (!$revision) { - return false; - } - - DB::Delete(self::table(), 'id = %i', (int) $revisionId); - return (int) $revision['block_id']; + return self::store()->delete($revisionId); } public static function deleteForBlock($blockId) { - $blockId = (int) $blockId; - if ($blockId <= 0) { - return 0; - } - - $count = (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE block_id = %i', $blockId)->getValue(); - DB::Delete(self::table(), 'block_id = %i', $blockId); - return $count; + return self::store()->deleteFor($blockId); } protected static function format(array $row, $withSnapshot) { - $labels = self::labels(); - $action = isset($row['action']) ? (string) $row['action'] : 'update'; - $meta = isset($labels[$action]) ? $labels[$action] : array('label' => $action, 'badge' => 'badge-gray'); - $snapshot = null; - if ($withSnapshot) { - $snapshot = Json::toArray((string) $row['snapshot_json']); - } - - $created = isset($row['created_at']) ? (int) $row['created_at'] : 0; + $row = self::store()->formatRow($row, $withSnapshot); + $snapshot = $row['snapshot']; $text = $withSnapshot && is_array($snapshot) && isset($snapshot['sysblock_text']) ? (string) $snapshot['sysblock_text'] : ''; return array( 'id' => (int) $row['id'], 'block_id' => (int) $row['block_id'], - 'action' => $action, - 'action_label' => $meta['label'], - 'badge' => $meta['badge'], + 'action' => $row['action'], + 'action_label' => $row['action_label'], + 'badge' => $row['badge'], 'comment' => (string) $row['comment'], 'author_id' => (int) $row['author_id'], 'author_name' => (string) $row['author_name'], - 'created_at' => $created, - 'created_label' => $created > 0 ? date('d.m.Y H:i:s', $created) : '-', + 'created_at' => $row['created_at'], + 'created_label' => $row['created_label'], 'source_revision_id' => (int) $row['source_revision_id'], 'snapshot_hash' => (string) $row['snapshot_hash'], 'text_hash' => (string) $row['text_hash'], 'text_size' => strlen($text), - 'text_size_label' => $withSnapshot ? self::formatBytes(strlen($text)) : '', + 'text_size_label' => $withSnapshot ? JsonRevisionStore::formatBytes(strlen($text)) : '', 'snapshot' => $snapshot, 'code' => $text, ); } - protected static function authorName($id) + protected static function store() { - if ((int) $id <= 0) { - return ''; + if (!self::$store) { + self::$store = new JsonRevisionStore(self::table(), 'block_id', self::labels()); } - $row = DB::query('SELECT name, login, email FROM ' . SystemTables::table('users') . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc(); - if (!$row) { - return '#' . (int) $id; - } - - if (!empty($row['name'])) { - return (string) $row['name']; - } - - if (!empty($row['login'])) { - return '@' . (string) $row['login']; - } - - if (!empty($row['email'])) { - return (string) $row['email']; - } - - return '#' . (int) $id; - } - - protected static function formatBytes($bytes) - { - $bytes = (int) $bytes; - if ($bytes < 1024) { - return $bytes . ' Б'; - } - - if ($bytes < 1048576) { - return round($bytes / 1024, 1) . ' KB'; - } - - return round($bytes / 1048576, 1) . ' MB'; + return self::$store; } } diff --git a/adminx/modules/Blocks/assets/blocks.css b/adminx/modules/Blocks/assets/blocks.css index 4daacb0..bce7e32 100644 --- a/adminx/modules/Blocks/assets/blocks.css +++ b/adminx/modules/Blocks/assets/blocks.css @@ -19,7 +19,6 @@ font-size: 20px; line-height: 1.1; font-weight: 800; - font-variant-numeric: tabular-nums; } .blocks-stat span { font-size: 13px; @@ -212,7 +211,6 @@ gap: 2px; color: var(--text-secondary); font-size: 12px; - font-variant-numeric: tabular-nums; } .blocks-actions { gap: 4px; diff --git a/adminx/modules/Blocks/assets/blocks.js b/adminx/modules/Blocks/assets/blocks.js index 9980cee..d62e998 100644 --- a/adminx/modules/Blocks/assets/blocks.js +++ b/adminx/modules/Blocks/assets/blocks.js @@ -151,6 +151,8 @@ applyFilters: function (form, push) { if (!form) { return; } + clearTimeout(this.filterTimer); + this.filterTimer = null; var url = this.filterUrl(form); this.applyFilterUrl(url, push); }, diff --git a/adminx/modules/Catalog/Controller.php b/adminx/modules/Catalog/Controller.php index 7947128..ed72e6b 100644 --- a/adminx/modules/Catalog/Controller.php +++ b/adminx/modules/Catalog/Controller.php @@ -28,6 +28,7 @@ use App\Helpers\Request; use App\Adminx\Rubrics\FieldAdminEditors; use App\Adminx\Documents\Model as DocumentsModel; + use App\Adminx\Support\BulkActionExecutor; use App\Adminx\Support\CodeEditor; use App\Adminx\Support\SavedViews; use App\Content\PaymentPrograms; @@ -42,11 +43,15 @@ use App\Adminx\Packages\Products\FilterTemplateRevisions; use App\Adminx\Packages\Products\FilterTemplates; use App\Adminx\Packages\Products\QuickEditor; + use App\Adminx\Packages\Products\QuickEditorColumns; + use App\Adminx\Packages\Products\QuickEditorProfiles; use App\Adminx\Packages\Products\ProductRelations; use App\Adminx\Packages\Products\ProductCollections; use App\Adminx\Packages\Products\ProductDemands; + use App\Adminx\Packages\Products\ProductReadiness; use App\Adminx\Packages\Products\ShippingPackageTemplates; use App\Adminx\Packages\Products\VariantGroups; + use App\Adminx\Packages\Commerce\Promotions; use App\Modules\Products\VariantSelectorSettings; use App\Modules\Products\ComparisonSettings; use App\Modules\Products\DemandService; @@ -74,7 +79,7 @@ public function products(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } $this->assets(); $filters = array( @@ -90,13 +95,13 @@ 'stats' => Model::productStats(), 'filters' => $filters, 'saved_views' => SavedViews::all('catalog_products', Auth::id(), $this->savedViewFields('products')), - 'can_manage' => Permission::check('manage_catalog'), + 'can_manage' => Permission::check('manage_products'), )); } public function productQuality(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } $this->assets(); $stats = Model::productStats(); @@ -120,25 +125,25 @@ 'issues' => $issues, 'filters' => $filters, 'saved_views' => SavedViews::all('catalog_quality', Auth::id(), $this->savedViewFields('quality')), - 'can_manage' => Permission::check('manage_catalog'), + 'can_manage' => Permission::check('manage_products'), )); } public function productComparison(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } $this->assets(); return $this->render('@products/product-comparison.twig', array( 'settings' => ComparisonSettings::get(), 'templates' => \App\Adminx\Rubrics\Model::templateOptions(), - 'can_manage' => Permission::check('manage_catalog'), + 'can_manage' => Permission::check('manage_products'), )); } public function saveProductComparison(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { $settings = ComparisonSettings::save(Request::postAll()); } catch (\InvalidArgumentException $e) { @@ -154,7 +159,7 @@ public function productDemands(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } $this->assets(); $type = Request::getStr('type', ''); @@ -174,13 +179,13 @@ 'filters' => $filters, 'settings' => DemandSettings::get(), 'templates' => \App\Adminx\Rubrics\Model::templateOptions(), - 'can_manage' => Permission::check('manage_catalog'), + 'can_manage' => Permission::check('manage_products'), )); } public function saveProductDemandSettings(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { $settings = DemandSettings::save(Request::postAll()); } catch (\InvalidArgumentException $e) { @@ -196,7 +201,7 @@ public function saveProductDemandStatus(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { $item = ProductDemands::setStatus( isset($params['id']) ? (int) $params['id'] : 0, @@ -212,7 +217,7 @@ public function notifyProductDemands(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; $result = DemandService::notifyReady($id > 0 ? 1 : 100, $id); $this->audit('catalog.product_demand_notifications_sent', $id, $result); @@ -224,7 +229,7 @@ public function deleteProductDemand(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { $item = ProductDemands::delete(isset($params['id']) ? (int) $params['id'] : 0); } catch (\InvalidArgumentException $e) { @@ -272,7 +277,7 @@ public function shipping(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } $this->shippingAssets(); $state = Request::getStr('shipping_state', ''); @@ -292,13 +297,13 @@ 'stats' => Model::productShippingStats(), 'filters' => $filters, 'package_templates' => ShippingPackageTemplates::all(), - 'can_manage' => Permission::check('manage_catalog'), + 'can_manage' => Permission::check('manage_products'), )); } public function productCollections(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } $this->collectionAssets(); return $this->render('@products/product-collections.twig', array( @@ -309,13 +314,13 @@ 'sort_modes' => ProductCollections::sortModes(), 'card_templates' => CardTemplates::options(), 'collections_available' => ProductCollections::available(), - 'can_manage' => Permission::check('manage_catalog'), + 'can_manage' => Permission::check('manage_products'), )); } public function productCollection(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } $item = ProductCollections::one(isset($params['id']) ? (int) $params['id'] : 0); return $item ? $this->success('', array('data' => array('collection' => $item))) : $this->error('Подборка не найдена', array(), 404); @@ -323,7 +328,7 @@ public function productCollectionProducts(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } return $this->success('', array('data' => array( 'items' => ProductCollections::productOptions(Request::getStr('q', ''), 30), ))); @@ -331,7 +336,7 @@ public function saveProductCollection(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; try { $item = ProductCollections::save($id, Request::postAll()); @@ -352,7 +357,7 @@ public function deleteProductCollection(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; if (!ProductCollections::delete($id)) { return $this->error('Подборка не найдена', array(), 404); @@ -364,26 +369,26 @@ public function productRelations(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } $this->relationAssets(); $type = Request::getStr('type', ''); return $this->render('@products/product-relations.twig', array( 'relations' => ProductRelations::relations($type), 'bundles' => ProductRelations::bundles(), 'types' => ProductRelations::types(), 'stats' => ProductRelations::stats(), 'selected_type' => $type, - 'can_manage' => Permission::check('manage_catalog'), + 'can_manage' => Permission::check('manage_products'), )); } public function productRelationProducts(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } return $this->success('', array('data' => array('items' => ProductRelations::productOptions(Request::getStr('q', ''), 40)))); } public function saveProductRelation(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; try { $relation = ProductRelations::saveRelation($id, Request::postAll()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } @@ -393,7 +398,7 @@ public function deleteProductRelation(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; if (!ProductRelations::deleteRelation($id)) { return $this->error('Связь не найдена', array(), 404); } $this->audit('catalog.product_relation_deleted', $id); @@ -402,14 +407,14 @@ public function productBundle(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } $bundle = ProductRelations::bundle(isset($params['id']) ? (int) $params['id'] : 0); return $bundle ? $this->success('', array('data' => array('bundle' => $bundle))) : $this->error('Комплект не найден', array(), 404); } public function saveProductBundle(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; $items = Request::postJsonArray('items'); if (!is_array($items)) { return $this->error('Некорректный состав комплекта', array(), 422); } @@ -421,7 +426,7 @@ public function deleteProductBundle(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; if (!ProductRelations::deleteBundle($id)) { return $this->error('Комплект не найден', array(), 404); } $this->audit('catalog.product_bundle_deleted', $id); @@ -430,7 +435,7 @@ public function productShipping(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } $productId = isset($params['id']) ? (int) $params['id'] : 0; $product = Model::product($productId); if (!$product) { return $this->error('Товар не найден', array(), 404); } @@ -443,19 +448,19 @@ public function quickEdit(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } $this->assets(); AdminAssets::addStyle('/modules/products/admin/assets/quick-edit.css', 53); AdminAssets::addScript('/modules/products/admin/assets/quick-edit.js', 52); $data = $this->quickEditData(); - if ($this->wantsPartial()) { return $this->partial('@catalog/partials/quick-edit-table.twig', $data); } + if ($this->wantsPartial()) { return $this->partial('@products/quick-edit-table.twig', $data); } return $this->render('@products/quick-edit.twig', $data); } public function cardTemplates(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } $this->cardTemplateAssets(); return $this->render('@products/card-templates.twig', array( 'templates' => CardTemplates::all(Request::getStr('q', '')), @@ -466,20 +471,20 @@ 'filters' => array('q' => Request::getStr('q', '')), 'products' => CardTemplates::productOptions(100), 'default_markup' => CardTemplates::defaultMarkup(), - 'can_manage' => Permission::check('manage_catalog'), + 'can_manage' => Permission::check('manage_products'), )); } public function cardTemplate(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } $item = CardTemplates::one(isset($params['id']) ? (int) $params['id'] : 0); return $item ? $this->success('', array('data' => $item)) : $this->error('Представление не найдено', array(), 404); } public function saveCardContext(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $code = isset($params['code']) ? (string) $params['code'] : ''; try { $context = CardContexts::save($code, Request::postAll(), Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } @@ -493,7 +498,7 @@ public function filterTemplates(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } $this->cardTemplateAssets(); return $this->render('@products/filter-templates.twig', array( 'templates' => FilterTemplates::all(Request::getStr('q', '')), @@ -501,13 +506,13 @@ 'filters' => array('q' => Request::getStr('q', '')), 'catalogs' => FilterTemplates::previewOptions(100), 'default_markup' => FilterTemplates::defaultMarkup(), - 'can_manage' => Permission::check('manage_catalog'), + 'can_manage' => Permission::check('manage_products'), )); } public function filterTemplate(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } $item = FilterTemplates::one(isset($params['id']) ? (int) $params['id'] : 0); return $item ? $this->success('', array('data' => $item)) : $this->error('Представление не найдено', array(), 404); } @@ -517,7 +522,7 @@ public function lintFilterTemplate(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $result = CardTemplateSyntax::check(Request::postStr('draft_markup', '')); return $result['ok'] ? $this->success($result['message'], array('data' => $result)) : $this->error($result['message'], array('draft_markup' => $result['message']), 422); @@ -525,7 +530,7 @@ public function previewFilterTemplate(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $markup = Request::postStr('draft_markup', ''); $css = Request::postStr('draft_css', ''); $syntax = CardTemplateSyntax::check($markup); if (!$syntax['ok']) { return $this->error($syntax['message'], array('draft_markup' => $syntax['message']), 422); } @@ -548,7 +553,7 @@ public function publishFilterTemplate(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; try { $item = FilterTemplates::publish($id, Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } $this->audit('catalog.filter_template_published', $id, array('version' => $item['version'])); return $this->success('Фильтры опубликованы', array('data' => $item)); @@ -556,14 +561,14 @@ public function copyFilterTemplate(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { $id = FilterTemplates::copy(isset($params['id']) ? (int) $params['id'] : 0, Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } $this->audit('catalog.filter_template_copied', $id); return $this->success('Копия создана', array('data' => array('id' => $id))); } public function deleteFilterTemplate(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; try { $deleted = FilterTemplates::delete($id); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } if (!$deleted) { return $this->error('Представление не найдено', array(), 404); } $this->audit('catalog.filter_template_deleted', $id); return $this->success('Представление удалено'); @@ -571,33 +576,33 @@ public function filterTemplateRevisions(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } $id = isset($params['id']) ? (int) $params['id'] : 0; $item = FilterTemplates::one($id); if (!$item) { return $this->error('Представление не найдено', array(), 404); } return $this->success('', array('data' => array('template' => array('id'=>$id,'title'=>$item['title']), 'revisions'=>FilterTemplateRevisions::listFor($id)))); } public function filterTemplateRevision(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } $item=FilterTemplateRevisions::one(isset($params['revision'])?(int)$params['revision']:0);return $item?$this->success('',array('data'=>$item)):$this->error('Ревизия не найдена',array(),404); } public function restoreFilterTemplateRevision(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try{$id=FilterTemplateRevisions::restore(isset($params['revision'])?(int)$params['revision']:0,Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);} $this->audit('catalog.filter_template_revision_restored',$id);return $this->success('Ревизия восстановлена в черновик',array('data'=>array('id'=>$id))); } public function deleteFilterTemplateRevision(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; }$id=FilterTemplateRevisions::delete(isset($params['revision'])?(int)$params['revision']:0); + if (($error = $this->productGuard()) !== null) { return $error; }$id=FilterTemplateRevisions::delete(isset($params['revision'])?(int)$params['revision']:0); return $id?$this->success('Ревизия удалена',array('data'=>array('id'=>$id))):$this->error('Ревизия не найдена',array(),404); } public function deleteFilterTemplateRevisions(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; }$id=isset($params['id'])?(int)$params['id']:0; + if (($error = $this->productGuard()) !== null) { return $error; }$id=isset($params['id'])?(int)$params['id']:0; if(!FilterTemplates::one($id)){return $this->error('Представление не найдено',array(),404);} return $this->success('Ревизии удалены',array('data'=>array('count'=>FilterTemplateRevisions::deleteFor($id)))); } @@ -614,7 +619,7 @@ public function lintCardTemplate(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $result = CardTemplateSyntax::check(Request::postStr('draft_markup', '')); return $result['ok'] ? $this->success($result['message'], array('data' => $result)) @@ -623,7 +628,7 @@ public function previewCardTemplate(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $markup = Request::postStr('draft_markup', ''); $css = Request::postStr('draft_css', ''); $syntax = CardTemplateSyntax::check($markup); @@ -651,7 +656,7 @@ public function publishCardTemplate(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; try { $item = CardTemplates::publish($id, Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } @@ -661,7 +666,7 @@ public function copyCardTemplate(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { $id = CardTemplates::copy(isset($params['id']) ? (int) $params['id'] : 0, Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } $this->audit('catalog.card_template_copied', $id); @@ -670,7 +675,7 @@ public function deleteCardTemplate(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; try { $deleted = CardTemplates::delete($id); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } @@ -681,7 +686,7 @@ public function cardTemplateRevisions(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } $id = isset($params['id']) ? (int) $params['id'] : 0; $item = CardTemplates::one($id); if (!$item) { return $this->error('Представление не найдено', array(), 404); } @@ -690,14 +695,14 @@ public function cardTemplateRevision(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } $item = CardTemplateRevisions::one(isset($params['revision']) ? (int) $params['revision'] : 0); return $item ? $this->success('', array('data' => $item)) : $this->error('Ревизия не найдена', array(), 404); } public function restoreCardTemplateRevision(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { $id = CardTemplateRevisions::restore(isset($params['revision']) ? (int) $params['revision'] : 0, Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } $this->audit('catalog.card_template_revision_restored', $id); @@ -706,14 +711,14 @@ public function deleteCardTemplateRevision(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = CardTemplateRevisions::delete(isset($params['revision']) ? (int) $params['revision'] : 0); return $id ? $this->success('Ревизия удалена', array('data' => array('id' => $id))) : $this->error('Ревизия не найдена', array(), 404); } public function deleteCardTemplateRevisions(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; if (!CardTemplates::one($id)) { return $this->error('Представление не найдено', array(), 404); } return $this->success('Ревизии удалены', array('data' => array('count' => CardTemplateRevisions::deleteFor($id)))); @@ -721,24 +726,26 @@ public function attributes(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } $this->attributeAssets(); - $view = Request::getStr('view', 'attributes'); - if (!in_array($view, array('normalization', 'attributes', 'sets', 'migration', 'sections'), true)) { $view = 'attributes'; } + $view = Request::getStr('view', 'overview'); + if (!in_array($view, array('overview', 'normalization', 'attributes', 'sets', 'migration', 'sections'), true)) { $view = 'overview'; } return $this->render('@products/attributes.twig', array( 'view' => $view, 'stats' => Attributes::stats(), 'value_types' => Attributes::valueTypes(), 'attributes' => Attributes::all(Request::getStr('q', ''), Request::getStr('status', '')), + 'product_attributes' => $view === 'migration' ? Attributes::all('', 'active', 'product') : array(), 'sets' => Attributes::sets(), 'legacy_fields' => $view === 'migration' ? Attributes::legacyFields() : array(), 'sections' => $view === 'sections' ? Attributes::sections() : array(), 'normalization' => $view === 'normalization' ? AttributeNormalization::audit() : array(), + 'overview' => $view === 'overview' ? Attributes::overview() : array(), 'filters' => array('q'=>Request::getStr('q',''),'status'=>Request::getStr('status','')), - 'can_manage' => Permission::check('manage_catalog'), + 'can_manage' => Permission::check('manage_products'), )); } public function prepareAttributeNormalization(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { $run = AttributeNormalization::prepare(Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } $this->audit('catalog.attribute_normalization_prepared', (int) $run['id'], $run['summary']); @@ -750,7 +757,7 @@ public function approveAttributeNormalization(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; try { $run = AttributeNormalization::approve($id, Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } @@ -763,14 +770,14 @@ public function attributeNormalizationReport(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } $run = AttributeNormalization::run(isset($params['id']) ? (int) $params['id'] : 0); return $run ? $this->success('', array('data' => $run)) : $this->error('Прогон не найден', array(), 404); } public function saveAttribute(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; try { $id = Attributes::save($id, Request::postAll(), Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } @@ -780,12 +787,12 @@ public function attribute(array $params = array()) { - if(!Permission::check('view_catalog')){return $this->error('Недостаточно прав',array(),403);}$item=Attributes::one(isset($params['id'])?(int)$params['id']:0);return $item?$this->success('',array('data'=>$item)):$this->error('Характеристика не найдена',array(),404); + if(!Permission::check('view_products')){return $this->error('Недостаточно прав',array(),403);}$item=Attributes::one(isset($params['id'])?(int)$params['id']:0);return $item?$this->success('',array('data'=>$item)):$this->error('Характеристика не найдена',array(),404); } public function deleteAttribute(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { Attributes::delete(isset($params['id']) ? (int) $params['id'] : 0); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } $this->audit('catalog.attribute_deleted', (int) $params['id']); @@ -794,16 +801,16 @@ public function attributeSet(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } $set = Attributes::set(isset($params['id']) ? (int) $params['id'] : 0); if (!$set) { return $this->renderStatus('@adminx/404.twig', array('title'=>'Набор не найден'), 404); } $this->attributeAssets(); - return $this->render('@products/attribute-set.twig', array('set'=>$set,'attributes'=>Attributes::all('', 'active'),'can_manage'=>Permission::check('manage_catalog'))); + return $this->render('@products/attribute-set.twig', array('set'=>$set,'attributes'=>Attributes::all('', 'active', 'product'),'can_manage'=>Permission::check('manage_products'))); } public function saveAttributeSet(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id=isset($params['id'])?(int)$params['id']:0; try{$id=Attributes::saveSet($id,Request::postAll(),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);} $this->audit('catalog.attribute_set_saved',$id);return $this->success('Набор сохранён',array('redirect'=>$this->base().'/catalog/attribute-sets/'.$id,'data'=>array('id'=>$id))); @@ -811,67 +818,67 @@ public function deleteAttributeSet(array $params = array()) { - if(($error=$this->guard())!==null){return $error;}try{Attributes::deleteSet((int)$params['id']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_set_deleted',(int)$params['id']);return $this->success('Набор удалён',array('redirect'=>$this->base().'/catalog/attributes?view=sets')); + if(($error=$this->productGuard())!==null){return $error;}try{Attributes::deleteSet((int)$params['id']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_set_deleted',(int)$params['id']);return $this->success('Набор удалён',array('redirect'=>$this->base().'/catalog/attributes?view=sets')); } public function saveAttributeGroup(array $params = array()) { - if(($error=$this->guard())!==null){return $error;}try{$id=Attributes::saveGroup((int)$params['id'],Request::postInt('group_id',0),Request::postStr('name',''));}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Группа сохранена',array('data'=>array('id'=>$id))); + if(($error=$this->productGuard())!==null){return $error;}try{$id=Attributes::saveGroup((int)$params['id'],Request::postInt('group_id',0),Request::postStr('name',''));}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Группа сохранена',array('data'=>array('id'=>$id))); } public function deleteAttributeGroup(array $params = array()) { - if(($error=$this->guard())!==null){return $error;}Attributes::deleteGroup((int)$params['id'],(int)$params['group']);return $this->success('Группа удалена, характеристики перенесены в «Без группы»'); + if(($error=$this->productGuard())!==null){return $error;}Attributes::deleteGroup((int)$params['id'],(int)$params['group']);return $this->success('Группа удалена, характеристики перенесены в «Без группы»'); } public function saveAttributeSetItem(array $params = array()) { - if(($error=$this->guard())!==null){return $error;}try{$id=Attributes::saveSetItem((int)$params['id'],Request::postAll());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Характеристика добавлена',array('data'=>array('id'=>$id))); + if(($error=$this->productGuard())!==null){return $error;}try{$id=Attributes::saveSetItem((int)$params['id'],Request::postAll());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Характеристика добавлена',array('data'=>array('id'=>$id))); } public function deleteAttributeSetItem(array $params = array()) { - if(($error=$this->guard())!==null){return $error;}Attributes::deleteSetItem((int)$params['id'],(int)$params['attribute']);return $this->success('Характеристика удалена из набора'); + if(($error=$this->productGuard())!==null){return $error;}Attributes::deleteSetItem((int)$params['id'],(int)$params['attribute']);return $this->success('Характеристика удалена из набора'); } public function saveAttributeMapping(array $params = array()) { - if(($error=$this->guard())!==null){return $error;}try{$data=Attributes::saveLegacyMap((int)$params['field'],Request::postAll(),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Сопоставление сохранено',array('data'=>$data)); + if(($error=$this->productGuard())!==null){return $error;}try{$data=Attributes::saveLegacyMap((int)$params['field'],Request::postAll(),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Сопоставление сохранено',array('data'=>$data)); } public function attributeMappingPreview(array $params = array()) { - if(!Permission::check('view_catalog')){return $this->error('Недостаточно прав',array(),403);}try{$data=Attributes::migrationPreview((int)$params['field']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('',array('data'=>$data)); + if(!Permission::check('view_products')){return $this->error('Недостаточно прав',array(),403);}try{$data=Attributes::migrationPreview((int)$params['field']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('',array('data'=>$data)); } public function migrateAttributeField(array $params = array()) { - if(($error=$this->guard())!==null){return $error;}try{$data=Attributes::migrate((int)$params['field'],Auth::id(),Request::postBool('verified',false));}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_field_migrated',(int)$params['field'],$data);return $this->success('Значения перенесены в новый контур',array('data'=>$data)); + if(($error=$this->productGuard())!==null){return $error;}try{$data=Attributes::migrate((int)$params['field'],Auth::id(),Request::postBool('verified',false));}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_field_migrated',(int)$params['field'],$data);return $this->success('Значения перенесены в новый контур',array('data'=>$data)); } public function verifyAttributeField(array $params = array()) { - if(($error=$this->guard())!==null){return $error;}try{$data=Attributes::verifyMigration((int)$params['field'],Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_field_verified',(int)$params['field'],$data);return $this->success('Перенесённые значения подтверждены',array('data'=>$data)); + if(($error=$this->productGuard())!==null){return $error;}try{$data=Attributes::verifyMigration((int)$params['field'],Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_field_verified',(int)$params['field'],$data);return $this->success('Перенесённые значения подтверждены',array('data'=>$data)); } public function saveAttributeSection(array $params = array()) { - if(($error=$this->guard())!==null){return $error;}try{$data=Attributes::saveSection((int)$params['id'],Request::postAll(),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Режим раздела сохранён',array('data'=>$data)); + if(($error=$this->productGuard())!==null){return $error;}try{$data=Attributes::saveSection((int)$params['id'],Request::postAll(),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Режим раздела сохранён',array('data'=>$data)); } public function rebuildAttributeShadow(array $params = array()) { - if(($error=$this->guard())!==null){return $error;}try{$data=Attributes::rebuildShadowIndex((int)$params['id']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_shadow_rebuilt',(int)$params['id'],$data);return $this->success('Теневой индекс раздела перестроен',array('data'=>$data)); + if(($error=$this->productGuard())!==null){return $error;}try{$data=Attributes::rebuildShadowIndex((int)$params['id']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_shadow_rebuilt',(int)$params['id'],$data);return $this->success('Теневой индекс раздела перестроен',array('data'=>$data)); } public function attributeShadowReport(array $params = array()) { - if(!Permission::check('view_catalog')){return $this->error('Недостаточно прав',array(),403);}try{$data=Attributes::shadowReport((int)$params['id']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('',array('data'=>$data)); + if(!Permission::check('view_products')){return $this->error('Недостаточно прав',array(),403);}try{$data=Attributes::shadowReport((int)$params['id']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('',array('data'=>$data)); } public function saveQuickEdit(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $productId = isset($params['id']) ? (int) $params['id'] : 0; $key = Request::postStr('key', ''); try { $saved = QuickEditor::save($productId, $key, Request::postStr('value', ''), Auth::id()); } @@ -880,9 +887,48 @@ return $this->success('Изменение сохранено', array('data' => $saved)); } + public function saveQuickEditProfile(array $params = array()) + { + if (($error = $this->productGuard()) !== null) { return $error; } + $columns = json_decode(Request::postStr('columns_json', '[]'), true); + if (!is_array($columns)) { return $this->error('Не удалось прочитать набор колонок', array(), 422); } + try { + $columns = QuickEditorColumns::normalize($columns); + $profile = QuickEditorProfiles::save( + Request::postInt('id', 0), + Auth::id(), + Request::postAll(), + $columns + ); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $this->audit('catalog.quick_edit_profile_saved', (int) $profile['id'], array( + 'title' => (string) $profile['title'], + 'columns' => count($columns), + 'shared' => (int) $profile['is_shared'], + )); + return $this->success('Набор колонок сохранён', array('data' => array('profile' => $profile))); + } + + public function deleteQuickEditProfile(array $params = array()) + { + if (($error = $this->productGuard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!QuickEditorProfiles::delete($id, Auth::id())) { + return $this->error('Профиль не найден или принадлежит другому пользователю', array(), 404); + } + + $this->audit('catalog.quick_edit_profile_deleted', $id); + return $this->success('Набор колонок удалён', array( + 'redirect' => $this->base() . '/catalog/quick-edit', + )); + } + public function setProductPaymentProgram(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $productId = isset($params['id']) ? (int) $params['id'] : 0; if (!Model::product($productId)) { return $this->error('Товар не найден', array(), 404); } try { $enabled = PaymentPrograms::setProduct($productId, isset($params['program']) ? $params['program'] : '', Request::postBool('enabled', false)); } @@ -905,19 +951,19 @@ public function variantGroups(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } $this->assets(); return $this->render('@products/variant-groups.twig', array( 'groups' => VariantGroups::all(), 'selector_settings' => VariantSelectorSettings::get(), - 'can_manage' => Permission::check('manage_catalog'), + 'can_manage' => Permission::check('manage_products'), )); } public function saveVariantSelectorSettings(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $settings = VariantSelectorSettings::save(Request::postAll()); $this->audit('catalog.variant_selector_settings_saved', 0, $settings); return $this->success('Вид выбора вариантов сохранён', array( @@ -928,7 +974,7 @@ public function variantGroup(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } $group = VariantGroups::one(isset($params['id']) ? (int) $params['id'] : 0); if (!$group) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Группа не найдена'), 404); } $this->shippingAssets(); @@ -939,13 +985,13 @@ 'variant_attributes' => VariantGroups::nativeAttributes(), 'package_templates' => ShippingPackageTemplates::all(), 'candidates' => $search !== '' ? Model::products(array('q' => $search, 'page' => 1)) : array('items' => array()), - 'can_manage' => Permission::check('manage_catalog'), + 'can_manage' => Permission::check('manage_products'), )); } public function saveVariantGroup(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; try { $id = VariantGroups::save($id, Request::postStr('title', ''), Request::postBool('status', false), Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } @@ -955,7 +1001,7 @@ public function addVariantProduct(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { VariantGroups::add((int) $params['id'], Request::postInt('product_id', 0)); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } $this->audit('catalog.variant_added', (int) $params['id'], array('product_id' => Request::postInt('product_id', 0))); @@ -964,7 +1010,7 @@ public function primaryVariantProduct(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { VariantGroups::primary((int) $params['id'], (int) $params['product']); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } $this->audit('catalog.variant_primary_changed', (int) $params['id'], array('product_id' => (int) $params['product'])); @@ -973,7 +1019,7 @@ public function removeVariantProduct(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { VariantGroups::remove((int) $params['id'], (int) $params['product']); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } $this->audit('catalog.variant_removed', (int) $params['id'], array('product_id' => (int) $params['product'])); @@ -982,7 +1028,7 @@ public function statusVariantProduct(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $status = Request::postBool('status', false); try { VariantGroups::status((int) $params['id'], (int) $params['product'], $status); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } @@ -992,7 +1038,7 @@ public function saveVariantAttribute(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { VariantGroups::saveAttribute((int) $params['id'], (int) $params['product'], Request::postInt('field_id', 0), Request::postStr('label', ''), Request::postStr('swatch', '')); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } $this->audit('catalog.variant_attribute_saved', (int) $params['id'], array('product_id' => (int) $params['product'], 'field_id' => Request::postInt('field_id', 0))); @@ -1001,7 +1047,7 @@ public function deleteVariantAttribute(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } VariantGroups::deleteAttribute((int) $params['id'], (int) $params['product'], (int) $params['field']); $this->audit('catalog.variant_attribute_deleted', (int) $params['id'], array('product_id' => (int) $params['product'], 'field_id' => (int) $params['field'])); return $this->success('Атрибут удалён'); @@ -1009,7 +1055,7 @@ public function saveVariantNativeAttribute(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $attributeId = Request::postInt('attribute_id', 0); try { VariantGroups::saveNativeAttribute( @@ -1031,7 +1077,7 @@ public function deleteVariantNativeAttribute(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } VariantGroups::deleteNativeAttribute((int) $params['id'], (int) $params['product'], (int) $params['attribute']); $this->audit('catalog.variant_native_attribute_deleted', (int) $params['id'], array( 'product_id' => (int) $params['product'], @@ -1042,7 +1088,7 @@ public function reorderVariantProducts(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $order = Request::postJsonArray('order'); if (!is_array($order)) { return $this->error('Некорректный порядок', array(), 422); } try { VariantGroups::reorder((int) $params['id'], $order); } @@ -1053,7 +1099,7 @@ public function copyVariantProduct(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $sourceId = (int) $params['product']; $newId = 0; try { @@ -1071,7 +1117,7 @@ public function deleteVariantGroup(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { VariantGroups::delete((int) $params['id']); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } $this->audit('catalog.variant_group_deleted', (int) $params['id']); @@ -1080,7 +1126,7 @@ public function reindexProducts(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { $result = Model::reindexProducts(); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 500); } $this->audit('catalog.products_reindexed', null, $result); @@ -1089,7 +1135,7 @@ public function reindexProduct(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; if ($id <= 0 || !DocumentsModel::one($id)) { return $this->error('Товар не найден', array(), 404); } try { @@ -1105,7 +1151,7 @@ public function createProductVariantGroup(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $productId = isset($params['id']) ? (int) $params['id'] : 0; $product = Model::product($productId); if (!$product) { return $this->error('Товар не найден', array(), 404); } @@ -1120,7 +1166,7 @@ public function editProduct(array $params = array()) { - if (!Permission::check('manage_catalog') || !Permission::check('manage_documents')) { + if (!Permission::check('manage_products') || !Permission::check('manage_documents')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } @@ -1132,6 +1178,9 @@ AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); CodeEditor::useRichEditor(); $this->attributeAssets(); + $variantGroup = VariantGroups::membership($id); + $shippingProfile = ProductShipping::profile($id); + $nativeAttributes = Attributes::productEditor($id); return $this->render('@documents/edit.twig', array( 'document' => $document, 'field_groups' => DocumentsModel::fieldsForDocument($id), @@ -1141,18 +1190,110 @@ 'authors' => DocumentsModel::authors(), 'is_new' => false, 'can_manage' => true, 'actor_id' => Auth::id(), - 'catalog_mode' => true, 'product' => $product, 'variant_group' => VariantGroups::membership($id), - 'shipping_profile' => ProductShipping::profile($id), + 'catalog_mode' => true, 'product' => $product, 'variant_group' => $variantGroup, + 'shipping_profile' => $shippingProfile, 'package_templates' => ShippingPackageTemplates::all(), - 'native_attributes' => Attributes::productEditor($id), + 'native_attributes' => $nativeAttributes, + 'product_readiness' => ProductReadiness::build($product, $nativeAttributes, $shippingProfile, $variantGroup), + 'product_promotions_available' => Promotions::available(), + 'product_promotions' => Promotions::forProduct($id), + 'can_manage_product_promotions' => Permission::check('manage_orders'), 'return_url' => $this->base() . '/catalog/products', 'submit_url' => $this->base() . '/documents/' . $id, )); } + public function saveProductPromotion(array $params = array()) + { + if (($error = $this->productPromotionGuard()) !== null) { return $error; } + $productId = isset($params['id']) ? (int) $params['id'] : 0; + $promotionId = isset($params['promotion']) ? (int) $params['promotion'] : 0; + $product = Model::product($productId); + if (!$product) { return $this->error('Товар не найден', array(), 404); } + if ($promotionId > 0 && !Promotions::isDirectProductRule($promotionId, $productId)) { + return $this->error('Эта акция управляется через общий конструктор', array(), 409); + } + + $rewardType = Request::postStr('reward_type', 'percent'); + $targetMode = Request::postStr('target_mode', 'current'); + $targetId = $targetMode === 'current' && $rewardType !== 'gift' + ? $productId + : Request::postInt('target_product_id', 0); + $title = Request::postStr('title', ''); + if ($title === '') { $title = 'Акция: ' . (string) $product['title']; } + $input = array( + 'title' => $title, + 'code' => Request::postStr('code', ''), + 'description' => Request::postStr('description', ''), + 'status' => Request::postBool('status', false), + 'condition_scope' => 'products', + 'condition_product_ids' => array($productId), + 'condition_quantity' => Request::postInt('condition_quantity', 1), + 'condition_min_total' => 0, + 'reward_type' => $rewardType, + 'reward_scope' => 'products', + 'reward_product_ids' => $rewardType === 'gift' ? array() : array($targetId), + 'reward_product_id' => $rewardType === 'gift' ? $targetId : 0, + 'reward_value' => Request::post('reward_value', 0), + 'reward_quantity' => Request::postInt('reward_quantity', 1), + 'max_uses_per_cart' => 0, + 'allow_coupon' => Request::postBool('allow_coupon', false), + 'stop_processing' => false, + 'starts_at' => Request::postStr('starts_at', ''), + 'ends_at' => Request::postStr('ends_at', ''), + 'priority' => Request::postInt('priority', 100), + ); + try { + $promotionId = Promotions::save($promotionId, $input); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $this->audit('catalog.product_promotion_saved', $productId, array('promotion_id' => $promotionId)); + return $this->success('Акция товара сохранена', array('data' => array('id' => $promotionId), 'reload' => true)); + } + + public function toggleProductPromotion(array $params = array()) + { + if (($error = $this->productPromotionGuard()) !== null) { return $error; } + $productId = isset($params['id']) ? (int) $params['id'] : 0; + $promotionId = isset($params['promotion']) ? (int) $params['promotion'] : 0; + if (!Promotions::isDirectProductRule($promotionId, $productId)) { + return $this->error('Эта акция управляется через общий конструктор', array(), 409); + } + + try { + Promotions::toggle($promotionId, Request::postBool('active', false)); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $this->audit('catalog.product_promotion_toggled', $productId, array('promotion_id' => $promotionId)); + return $this->success('Состояние акции обновлено'); + } + + public function deleteProductPromotion(array $params = array()) + { + if (($error = $this->productPromotionGuard()) !== null) { return $error; } + $productId = isset($params['id']) ? (int) $params['id'] : 0; + $promotionId = isset($params['promotion']) ? (int) $params['promotion'] : 0; + if (!Promotions::isDirectProductRule($promotionId, $productId)) { + return $this->error('Эта акция управляется через общий конструктор', array(), 409); + } + + try { + Promotions::delete($promotionId); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $this->audit('catalog.product_promotion_deleted', $productId, array('promotion_id' => $promotionId)); + return $this->success('Акция товара удалена', array('reload' => true)); + } + public function saveProductShipping(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $productId = isset($params['id']) ? (int) $params['id'] : 0; $packages = Request::postJsonArray('packages'); if (!is_array($packages)) { return $this->error('Некорректный список грузовых мест', array(), 422); } @@ -1164,12 +1305,12 @@ public function saveProductAttributes(array $params = array()) { - if(($error=$this->guard())!==null){return $error;}$productId=isset($params['id'])?(int)$params['id']:0;if(!Model::product($productId)){return $this->error('Товар не найден',array(),404);}try{$data=Attributes::saveProductValues($productId,Request::postAll(),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.product_attributes_saved',$productId,$data);return $this->success('Характеристики товара сохранены',array('data'=>$data)); + if(($error=$this->productGuard())!==null){return $error;}$productId=isset($params['id'])?(int)$params['id']:0;if(!Model::product($productId)){return $this->error('Товар не найден',array(),404);}try{$data=Attributes::saveProductValues($productId,Request::postAll(),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.product_attributes_saved',$productId,$data);return $this->success('Характеристики товара сохранены',array('data'=>$data)); } public function copyProductShippingToVariants(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $productId = isset($params['id']) ? (int)$params['id'] : 0; $membership = VariantGroups::membership($productId); if (!$membership) { return $this->error('Товар не входит в группу вариантов', array(), 422); } $group = VariantGroups::one((int)$membership['id']); $targets = array(); @@ -1182,22 +1323,64 @@ public function bulkProductShipping(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $ids=Request::postJsonArray('ids');if(!is_array($ids)||count($ids)<1||count($ids)>200){return $this->error('Выберите от 1 до 200 товаров',array(),422);} $enabled=Request::postBool('enabled',false);$done=ProductShipping::setEnabled($ids,$enabled); $this->audit('catalog.product_shipping_bulk_status',null,array('enabled'=>$enabled,'updated'=>$done)); return $this->success(($enabled?'Расчёт включён: ':'Расчёт выключен: ').$done,array('data'=>array('updated'=>$done))); } + public function bulkProducts(array $params = array()) + { + if (($error = $this->productGuard()) !== null) { return $error; } + $authorId = Auth::id(); + try { + $result = BulkActionExecutor::execute( + Request::postStr('action', ''), + Request::post('ids', array()), + array( + 'publish' => function ($id) use ($authorId) { + QuickEditor::save($id, 'active', '1', $authorId); + return true; + }, + 'disable' => function ($id) use ($authorId) { + QuickEditor::save($id, 'active', '0', $authorId); + return true; + }, + 'show_price' => function ($id) use ($authorId) { + QuickEditor::save($id, 'show_price', '1', $authorId); + return true; + }, + 'hide_price' => function ($id) use ($authorId) { + QuickEditor::save($id, 'show_price', '0', $authorId); + return true; + }, + 'reindex' => function ($id) { + Model::reindexDocument($id); + return true; + }, + ), + 300 + ); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $this->audit('catalog.products_bulk_action', null, $result); + $message = 'Обработано товаров: ' . (int) $result['done']; + if (!empty($result['errors'])) { $message .= ', ошибок: ' . count($result['errors']); } + return $this->success($message, array('data' => $result)); + } + public function shippingPackageTemplates(array $params = array()) { - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } return $this->success('', array('data' => array('templates' => ShippingPackageTemplates::all()))); } public function saveShippingPackageTemplate(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; try { $template = ShippingPackageTemplates::save($id, Request::postAll()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } @@ -1210,7 +1393,7 @@ public function deleteShippingPackageTemplate(array $params = array()) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; $template = ShippingPackageTemplates::one($id); if (!$template) { return $this->error('Шаблон упаковки не найден', array(), 404); } @@ -1221,7 +1404,7 @@ public function createProduct(array $params = array()) { - if (!Permission::check('manage_catalog') || !Permission::check('manage_documents')) { + if (!Permission::check('manage_products') || !Permission::check('manage_documents')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } @@ -1410,7 +1593,7 @@ protected function persistCardTemplate($id) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { $saved = CardTemplates::save((int) $id, Request::postAll(), Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array('draft_markup' => $e->getMessage()), 422); } $this->audit($id > 0 ? 'catalog.card_template_saved' : 'catalog.card_template_created', $saved); @@ -1419,7 +1602,7 @@ protected function persistFilterTemplate($id) { - if (($error = $this->guard()) !== null) { return $error; } + if (($error = $this->productGuard()) !== null) { return $error; } try { $saved = FilterTemplates::save((int) $id, Request::postAll(), Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array('draft_markup' => $e->getMessage()), 422); } $this->audit($id > 0 ? 'catalog.filter_template_saved' : 'catalog.filter_template_created', $saved); @@ -1445,10 +1628,25 @@ return $this->guardPermission('manage_catalog'); } + protected function productGuard() + { + return $this->guardPermission('manage_products'); + } + + protected function productPromotionGuard() + { + if (($error = $this->csrfGuard()) !== null) { return $error; } + if (!Permission::check('manage_products') || !Permission::check('manage_orders')) { + return $this->error('Недостаточно прав для управления акциями', array(), 403); + } + + return null; + } + protected function savedViewGuard() { if (($error = $this->csrfGuard()) !== null) { return $error; } - if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } return null; } @@ -1475,13 +1673,21 @@ 'q' => Request::getStr('q', ''), 'rubric_id' => Request::getInt('rubric_id', 0), 'category_id' => Request::getInt('category_id', 0), 'state' => Request::getStr('state', ''), 'incomplete' => Request::getBool('incomplete', false), 'limit' => Request::getInt('limit', 25), - 'page' => Request::getInt('page', 1), + 'page' => Request::getInt('page', 1), 'profile' => Request::getStr('profile', ''), ); $filters = Hooks::filter('catalog.quick_edit.filters', $filters); if (!is_array($filters)) { $filters = array(); } - return array('result' => QuickEditor::items($filters), 'filters' => $filters, + $profiles = QuickEditorProfiles::all(Auth::id()); + $activeProfile = QuickEditorProfiles::active(isset($filters['profile']) ? $filters['profile'] : '', Auth::id()); + $columns = QuickEditorColumns::normalize(isset($activeProfile['columns']) ? $activeProfile['columns'] : array()); + $filters['profile'] = (string) $activeProfile['id']; + return array('result' => QuickEditor::items($filters, $columns), 'filters' => $filters, 'rubrics' => QuickEditor::rubrics(), 'categories' => QuickEditor::categories(), - 'can_manage' => Permission::check('manage_catalog')); + 'can_manage' => Permission::check('manage_products'), + 'quick_profiles' => $profiles, + 'quick_profile' => array_merge($activeProfile, array('columns' => $columns)), + 'quick_column_groups' => QuickEditorColumns::groups(), + 'quick_profiles_available' => QuickEditorProfiles::available()); } protected function assets() diff --git a/adminx/modules/Catalog/assets/catalog-drawer.css b/adminx/modules/Catalog/assets/catalog-drawer.css index 248f88c..996216d 100644 --- a/adminx/modules/Catalog/assets/catalog-drawer.css +++ b/adminx/modules/Catalog/assets/catalog-drawer.css @@ -217,7 +217,6 @@ gap: 12px; flex: 0 0 auto; font-size: 11px; - font-variant-numeric: tabular-nums; } .catalog-field-groups { display: grid; @@ -299,7 +298,6 @@ flex: 0 0 auto; color: var(--text-secondary); font-size: 11px; - font-variant-numeric: tabular-nums; } .catalog-field-group-meta .badge { min-width: 88px; @@ -459,7 +457,6 @@ margin-bottom: 10px; color: var(--text-secondary); font-size: 13px; - font-variant-numeric: tabular-nums; } .catalog-recompile-errors { margin-top: 14px; diff --git a/adminx/modules/Catalog/assets/catalog.css b/adminx/modules/Catalog/assets/catalog.css index 6f5e739..76d8e86 100644 --- a/adminx/modules/Catalog/assets/catalog.css +++ b/adminx/modules/Catalog/assets/catalog.css @@ -1,4 +1,4 @@ -.catalog-summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:16px}.catalog-stat{display:flex;align-items:center;gap:14px;padding:16px}.catalog-stat b{display:block;font-size:20px;line-height:1.1;font-weight:800;font-variant-numeric:tabular-nums}.catalog-stat span{font-size:13px;color:var(--text-secondary)}.catalog-panel-header{margin-bottom:18px}.catalog-panel-header h2{font-size:22px;line-height:1.2;font-weight:800;letter-spacing:0}.catalog-card{padding:0;overflow:hidden}.catalog-section-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:18px 20px;border-bottom:1px solid var(--border-default)}.catalog-section-title{display:flex;align-items:flex-start;gap:12px;min-width:0}.catalog-section-title h2{margin:0 0 4px;font-size:16px;line-height:1.25;font-weight:700;letter-spacing:0}.catalog-section-title p{margin:0;font-size:13px}.catalog-head-icon{width:34px;height:34px;border-radius:var(--radius-md);font-size:17px}.catalog-table{width:100%;table-layout:fixed}.catalog-table td b{display:block}.catalog-meta{display:block;margin-top:3px;color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.catalog-actions-col{width:140px}.catalog-table a{text-decoration:none}.catalog-page-tabs{margin-bottom:18px;overflow-x:auto}.catalog-page-tabs .tab{text-decoration:none;white-space:nowrap}.catalog-tree-search{width:min(320px,40vw)}.catalog-tree,.catalog-tree-children{margin:0;padding:0;list-style:none}.catalog-tree{padding:8px 0}.catalog-tree-children{margin-left:34px;border-left:1px solid var(--border-default)}.catalog-tree-row{display:grid;grid-template-columns:30px 22px minmax(180px,1fr) 80px 70px 84px;align-items:center;gap:8px;min-height:54px;padding:7px 16px;border-bottom:1px solid var(--border-subtle,var(--border-default))}.catalog-tree-item:last-child>.catalog-tree-row{border-bottom:0}.catalog-tree-item.is-filtered{display:none}.catalog-tree-item.is-dragging>.catalog-tree-row{opacity:.55;background:var(--blue-50)}.catalog-drag{display:grid;place-items:center;width:30px;height:30px;padding:0;border:0;background:transparent;color:var(--text-tertiary);cursor:grab}.catalog-drag:active{cursor:grabbing}.catalog-tree-branch{color:var(--text-tertiary)}.catalog-tree-name{min-width:0}.catalog-tree-name b,.catalog-tree-name small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.catalog-tree-name b{font-size:13px}.catalog-tree-name small{font-size:11px;color:var(--text-secondary)}.catalog-tree-count{text-align:center;font-size:12px;font-variant-numeric:tabular-nums;color:var(--text-secondary)}.catalog-tree-actions{display:flex;justify-content:flex-end;gap:4px}.catalog-action-edit{color:var(--blue-600)}.catalog-action-delete{color:var(--red-600)}.catalog-card-body{padding:18px 20px}.catalog-settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;padding:0 20px 20px}.catalog-settings-grid>section{min-width:0}.catalog-settings-grid h3{margin:0 0 4px;font-size:14px}.catalog-check-list,.catalog-filter-list{display:grid;gap:6px;max-height:440px;margin-top:12px;overflow-y:auto}.catalog-check-list label,.catalog-filter-row{min-width:0;padding:9px 10px;border:1px solid var(--border-default);border-radius:var(--radius-md);background:var(--background-card)}.catalog-check-list label{display:grid;grid-template-columns:auto minmax(0,1fr);column-gap:9px;align-items:center}.catalog-check-list small{grid-column:2;color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.catalog-filter-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(160px,210px);align-items:center;gap:10px}.catalog-filter-row label{display:flex;align-items:center;gap:9px;min-width:0}.catalog-filter-row label span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.catalog-form-footer{display:flex;justify-content:flex-end;padding:14px 20px;border-top:1px solid var(--border-default)}.catalog-drawer{width:min(1000px,66.666vw);max-width:none}.catalog-item-form{display:block;overflow-x:hidden}.catalog-drawer-tabs{position:sticky;top:0;z-index:2;margin:-20px -20px 18px;padding:12px 20px;border-bottom:1px solid var(--border-default);background:var(--background-card)}.catalog-drawer-tabs .tab{text-decoration:none}.catalog-item-form [data-catalog-drawer-panel]{min-width:0}@media(max-width:900px){.catalog-settings-grid{grid-template-columns:1fr}.catalog-drawer{width:min(100%,720px)}.catalog-tree-row{grid-template-columns:30px 18px minmax(140px,1fr) 70px 70px}.catalog-tree-count{display:none}}@media(max-width:640px){.catalog-section-head{align-items:stretch;flex-direction:column}.catalog-tree-search{width:100%}.catalog-tree-row{grid-template-columns:28px minmax(120px,1fr) 70px 76px}.catalog-tree-branch{display:none}.catalog-tree-children{margin-left:16px}.catalog-settings-grid{padding:0 14px 14px}.catalog-filter-row{grid-template-columns:1fr}.catalog-table th:nth-child(3),.catalog-table td:nth-child(3){display:none}} +.catalog-summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:16px}.catalog-stat{display:flex;align-items:center;gap:14px;padding:16px}.catalog-stat b{display:block;font-size:20px;line-height:1.1;font-weight:800;}.catalog-stat span{font-size:13px;color:var(--text-secondary)}.catalog-panel-header{margin-bottom:18px}.catalog-panel-header h2{font-size:22px;line-height:1.2;font-weight:800;letter-spacing:0}.catalog-card{padding:0;overflow:hidden}.catalog-section-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:18px 20px;border-bottom:1px solid var(--border-default)}.catalog-section-title{display:flex;align-items:flex-start;gap:12px;min-width:0}.catalog-section-title h2{margin:0 0 4px;font-size:16px;line-height:1.25;font-weight:700;letter-spacing:0}.catalog-section-title p{margin:0;font-size:13px}.catalog-head-icon{width:34px;height:34px;border-radius:var(--radius-md);font-size:17px}.catalog-table{width:100%;table-layout:fixed}.catalog-table td b{display:block}.catalog-meta{display:block;margin-top:3px;color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.catalog-actions-col{width:140px}.catalog-table a{text-decoration:none}.catalog-page-tabs{margin-bottom:18px;overflow-x:auto}.catalog-page-tabs .tab{text-decoration:none;white-space:nowrap}.catalog-tree-search{width:min(320px,40vw)}.catalog-tree,.catalog-tree-children{margin:0;padding:0;list-style:none}.catalog-tree{padding:8px 0}.catalog-tree-children{margin-left:34px;border-left:1px solid var(--border-default)}.catalog-tree-row{display:grid;grid-template-columns:30px 22px minmax(180px,1fr) 80px 70px 84px;align-items:center;gap:8px;min-height:54px;padding:7px 16px;border-bottom:1px solid var(--border-subtle,var(--border-default))}.catalog-tree-item:last-child>.catalog-tree-row{border-bottom:0}.catalog-tree-item.is-filtered{display:none}.catalog-tree-item.is-dragging>.catalog-tree-row{opacity:.55;background:var(--blue-50)}.catalog-drag{display:grid;place-items:center;width:30px;height:30px;padding:0;border:0;background:transparent;color:var(--text-tertiary);cursor:grab}.catalog-drag:active{cursor:grabbing}.catalog-tree-branch{color:var(--text-tertiary)}.catalog-tree-name{min-width:0}.catalog-tree-name b,.catalog-tree-name small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.catalog-tree-name b{font-size:13px}.catalog-tree-name small{font-size:11px;color:var(--text-secondary)}.catalog-tree-count{text-align:center;font-size:12px;color:var(--text-secondary)}.catalog-tree-actions{display:flex;justify-content:flex-end;gap:4px}.catalog-action-edit{color:var(--blue-600)}.catalog-action-delete{color:var(--red-600)}.catalog-card-body{padding:18px 20px}.catalog-settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;padding:0 20px 20px}.catalog-settings-grid>section{min-width:0}.catalog-settings-grid h3{margin:0 0 4px;font-size:14px}.catalog-check-list,.catalog-filter-list{display:grid;gap:6px;max-height:440px;margin-top:12px;overflow-y:auto}.catalog-check-list label,.catalog-filter-row{min-width:0;padding:9px 10px;border:1px solid var(--border-default);border-radius:var(--radius-md);background:var(--background-card)}.catalog-check-list label{display:grid;grid-template-columns:auto minmax(0,1fr);column-gap:9px;align-items:center}.catalog-check-list small{grid-column:2;color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.catalog-filter-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(160px,210px);align-items:center;gap:10px}.catalog-filter-row label{display:flex;align-items:center;gap:9px;min-width:0}.catalog-filter-row label span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.catalog-form-footer{display:flex;justify-content:flex-end;padding:14px 20px;border-top:1px solid var(--border-default)}.catalog-drawer{width:min(1000px,66.666vw);max-width:none}.catalog-item-form{display:block;overflow-x:hidden}.catalog-drawer-tabs{position:sticky;top:0;z-index:2;margin:-20px -20px 18px;padding:12px 20px;border-bottom:1px solid var(--border-default);background:var(--background-card)}.catalog-drawer-tabs .tab{text-decoration:none}.catalog-item-form [data-catalog-drawer-panel]{min-width:0}@media(max-width:900px){.catalog-settings-grid{grid-template-columns:1fr}.catalog-drawer{width:min(100%,720px)}.catalog-tree-row{grid-template-columns:30px 18px minmax(140px,1fr) 70px 70px}.catalog-tree-count{display:none}}@media(max-width:640px){.catalog-section-head{align-items:stretch;flex-direction:column}.catalog-tree-search{width:100%}.catalog-tree-row{grid-template-columns:28px minmax(120px,1fr) 70px 76px}.catalog-tree-branch{display:none}.catalog-tree-children{margin-left:16px}.catalog-settings-grid{padding:0 14px 14px}.catalog-filter-row{grid-template-columns:1fr}.catalog-table th:nth-child(3),.catalog-table td:nth-child(3){display:none}} .catalog-panel+.catalog-panel{margin-top:22px} .catalog-product-card-settings{margin-top:18px;padding-top:18px;border-top:1px solid var(--border-default)} .catalog-product-card-layout{display:grid;grid-template-columns:minmax(280px,.8fr) minmax(360px,1.2fr);gap:22px;margin-top:14px} diff --git a/adminx/modules/Catalog/view/edit.twig b/adminx/modules/Catalog/view/edit.twig index 24cd541..498f11a 100644 --- a/adminx/modules/Catalog/view/edit.twig +++ b/adminx/modules/Catalog/view/edit.twig @@ -38,7 +38,7 @@
- +
{% if products_available %}

Поля товарного представления

{% set commerce_fields={'product_title_field_id':'Название товара','product_article_field_id':'Артикул','product_price_field_id':'Цена','product_old_price_field_id':'Старая цена','product_stock_field_id':'Остаток','product_images_field_id':'Изображения'} %}{% for key,label in commerce_fields %}{% endfor %}
diff --git a/adminx/modules/Customers/assets/customers.css b/adminx/modules/Customers/assets/customers.css index 38c64dd..d1e5077 100644 --- a/adminx/modules/Customers/assets/customers.css +++ b/adminx/modules/Customers/assets/customers.css @@ -33,9 +33,6 @@ .customers-table td:last-child { text-align: right; } -.customers-table td:first-child { - font-variant-numeric: tabular-nums; -} .customer-editor-drawer { width: min(66.666vw, 1240px); max-width: none; @@ -83,7 +80,6 @@ min-width: 0; overflow: hidden; font-size: 13px; - font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; } diff --git a/adminx/modules/Dashboard/assets/dashboard.css b/adminx/modules/Dashboard/assets/dashboard.css index dc79a97..295abd0 100644 --- a/adminx/modules/Dashboard/assets/dashboard.css +++ b/adminx/modules/Dashboard/assets/dashboard.css @@ -10,7 +10,6 @@ gap: 8px; color: var(--text-secondary); font-size: 13px; - font-variant-numeric: tabular-nums; } /* ── KPI ───────────────────────────────────────────── */ .dashboard-kpis { @@ -62,7 +61,6 @@ color: var(--text-primary); font-size: 24px; line-height: 1.1; - font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } .dashboard-delta { @@ -75,7 +73,6 @@ border-radius: 999px; font-size: 12px; font-weight: 600; - font-variant-numeric: tabular-nums; } .dashboard-delta .ti { font-size: 14px; @@ -154,7 +151,6 @@ color: var(--text-primary); font-size: 18px; font-weight: 700; - font-variant-numeric: tabular-nums; } .dashboard-chart-legend small { color: var(--text-secondary); @@ -216,7 +212,6 @@ border-top: 1px solid var(--border-default); color: var(--text-muted); font-size: 11px; - font-variant-numeric: tabular-nums; } .dashboard-chart-axis .is-weekend { color: var(--text-secondary); @@ -253,7 +248,6 @@ .dashboard-status-count { font-weight: 700; font-size: 13px; - font-variant-numeric: tabular-nums; } .dashboard-status-bar { grid-column: 1 / -1; @@ -350,7 +344,6 @@ .dashboard-money { font-weight: 700; white-space: nowrap; - font-variant-numeric: tabular-nums; } .dashboard-status-tag { display: flex; @@ -412,7 +405,6 @@ margin-top: 4px; color: var(--text-secondary); font-size: 11.5px; - font-variant-numeric: tabular-nums; } .dashboard-document-list > a > .ti { color: var(--text-muted); diff --git a/adminx/modules/Database/Backup.php b/adminx/modules/Database/Backup.php index e6c9275..e6ef436 100644 --- a/adminx/modules/Database/Backup.php +++ b/adminx/modules/Database/Backup.php @@ -58,7 +58,7 @@ 'size' => filesize($path), 'size_h' => Model::human(filesize($path)), 'mtime' => filemtime($path), - 'own' => strpos($f, 'adminx_') === 0, + 'own' => self::isManagedName($f), ); } @@ -111,7 +111,7 @@ } $dir = self::ensureDir(); - $name = self::uniqueName('adminx_uploaded_' . date('Ymd_His'), $extension); + $name = self::uniqueName('avecms_uploaded_' . date('Ymd_His'), $extension); $temporary = $dir . DS . '.upload-' . bin2hex(random_bytes(6)) . $extension; if (!move_uploaded_file($file['tmp_name'], $temporary)) { throw new \RuntimeException('Не удалось сохранить загруженный файл'); @@ -151,11 +151,11 @@ $prefixes = Model::prefixes(); $purpose = preg_replace('/[^a-z0-9_-]+/', '', strtolower((string) $purpose)); - $name = self::uniqueName('adminx_' . ($purpose !== '' ? $purpose . '_' : '') . implode('-', $prefixes) . '_' . date('Ymd_His'), '.sql.gz'); + $name = self::uniqueName('avecms_' . ($purpose !== '' ? $purpose . '_' : '') . implode('-', $prefixes) . '_' . date('Ymd_His'), '.sql.gz'); $path = $dir . DS . $name; $temporary = $path . '.part'; - $lockPath = $dir . DS . '.adminx-backup.lock'; + $lockPath = $dir . DS . '.avecms-backup.lock'; $lock = fopen($lockPath, 'c'); if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) { if ($lock) { fclose($lock); } @@ -169,7 +169,7 @@ throw new \RuntimeException('Не удалось создать файл дампа'); } - gzwrite($fp, "-- adminx full dump\n-- database: " . Model::databaseName() . "\n-- prefixes: {{prefix}}\n-- source-prefix: " . Model::basePrefix() . + gzwrite($fp, "-- AVE.cms full dump\n-- database: " . Model::databaseName() . "\n-- prefixes: {{prefix}}\n-- source-prefix: " . Model::basePrefix() . "\n-- date: " . date('Y-m-d H:i:s') . "\n\nSET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n\n"); $mysqli = DB::mysqli(); @@ -205,6 +205,13 @@ return array('name' => $name, 'size' => filesize($path), 'tables' => count($tables), 'prefixes' => $prefixes); } + /** Новые имена AVE.cms и старые adminx-дампы поддерживаются одинаково. */ + public static function isManagedName($name) + { + $name = basename((string) $name); + return strpos($name, 'avecms_') === 0 || strpos($name, 'adminx_') === 0; + } + protected static function dumpTable($fp, $table, $mysqli, $progress = null, $current = 0, $total = 0) { $portableTable = Model::portableTableName($table); diff --git a/adminx/modules/Database/BackupRestore.php b/adminx/modules/Database/BackupRestore.php index bd151ef..43af874 100644 --- a/adminx/modules/Database/BackupRestore.php +++ b/adminx/modules/Database/BackupRestore.php @@ -32,7 +32,7 @@ public static function inspect($name, $includeToken = true) { $path = Backup::path($name); - if ($path === null || strpos(basename($path), 'adminx_') !== 0) { + if ($path === null || !Backup::isManagedName($path)) { throw new \RuntimeException('Для восстановления выберите полный дамп, созданный AVE.cms'); } diff --git a/adminx/modules/Database/assets/database.css b/adminx/modules/Database/assets/database.css index 3ed41ed..4ac0f57 100644 --- a/adminx/modules/Database/assets/database.css +++ b/adminx/modules/Database/assets/database.css @@ -28,7 +28,6 @@ } .db-tables-count { flex: 0 0 auto; - font-variant-numeric: tabular-nums; } /* Размеры/отступы заголовка/сводки — общие (.ax-panel-header/.ax-summary). Здесь только акцент. */ .database-panel-header { @@ -98,7 +97,6 @@ .database-restore-head strong { color: var(--blue-600); font-size: 22px; - font-variant-numeric: tabular-nums; } .database-restore-track { height: 6px; @@ -132,7 +130,6 @@ .database-restore-current small { margin-top: 3px; color: var(--text-secondary); - font-variant-numeric: tabular-nums; } .database-restore-pulse { width: 10px; diff --git a/adminx/modules/Database/view/index.twig b/adminx/modules/Database/view/index.twig index 0479490..8aa8717 100644 --- a/adminx/modules/Database/view/index.twig +++ b/adminx/modules/Database/view/index.twig @@ -151,7 +151,7 @@ {{ b.name }} - {% if b.own %}adminx{% endif %} + {% if b.own %}AVE.cms{% endif %} {{ b.size_h }} diff --git a/adminx/modules/Directories/Controller.php b/adminx/modules/Directories/Controller.php new file mode 100644 index 0000000..49b4839 --- /dev/null +++ b/adminx/modules/Directories/Controller.php @@ -0,0 +1,189 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Directories; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\AuditLog; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Content\Directories\DirectoryRepository; + use App\Helpers\Request; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + if (!Permission::check('view_rubrics')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + AdminAssets::addStyle($this->base() . '/modules/Rubrics/assets/rubrics.css', 49); + AdminAssets::addStyle($this->base() . '/modules/Directories/assets/directories.css', 50); + AdminAssets::addScript($this->base() . '/modules/Directories/assets/directories.js', 50); + $query = Request::getStr('q', ''); + $directories = Model::all($query); + $selectedId = max(0, Request::getInt('id', 0)); + $selected = $selectedId > 0 ? DirectoryRepository::find($selectedId) : null; + if ($selected) { + $selectedUsage = null; + foreach ($directories as $directory) { + if ((int) $directory['id'] === $selectedId) { + $selectedUsage = (int) $directory['usage_count']; + break; + } + } + + $selected['usage_count'] = $selectedUsage === null + ? DirectoryRepository::usageCount($selectedId) + : $selectedUsage; + } + + return $this->render('@directories/index.twig', array( + 'directories' => $directories, + 'selected' => $selected, + 'items' => $selected ? DirectoryRepository::items($selectedId) : array(), + 'stats' => Model::stats(), + 'schema_ready' => Model::schemaReady(), + 'filters' => array('q' => $query), + 'can_manage' => Permission::check('manage_rubrics'), + )); + } + + public function store(array $params = array()) + { + if (($error = $this->manageGuard()) !== null) { + return $error; + } + + return $this->saveDirectory(0, 'Справочник создан', 'directory.created'); + } + + public function update(array $params = array()) + { + if (($error = $this->manageGuard()) !== null) { + return $error; + } + + return $this->saveDirectory(isset($params['id']) ? (int) $params['id'] : 0, 'Справочник сохранён', 'directory.updated'); + } + + public function delete(array $params = array()) + { + if (($error = $this->manageGuard()) !== null) { + return $error; + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { + if (!DirectoryRepository::delete($id)) { + return $this->error('Справочник не найден', array(), 404); + } + + $this->audit('directory.deleted', $id); + return $this->success('Справочник удалён', array( + 'redirect' => $this->base() . '/directories', + )); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + public function storeItem(array $params = array()) + { + if (($error = $this->manageGuard()) !== null) { + return $error; + } + + return $this->saveItem(isset($params['id']) ? (int) $params['id'] : 0, 0, 'Значение добавлено', 'directory.item_created'); + } + + public function updateItem(array $params = array()) + { + if (($error = $this->manageGuard()) !== null) { + return $error; + } + + return $this->saveItem( + isset($params['id']) ? (int) $params['id'] : 0, + isset($params['item']) ? (int) $params['item'] : 0, + 'Значение сохранено', + 'directory.item_updated' + ); + } + + public function deleteItem(array $params = array()) + { + if (($error = $this->manageGuard()) !== null) { + return $error; + } + + $directoryId = isset($params['id']) ? (int) $params['id'] : 0; + $itemId = isset($params['item']) ? (int) $params['item'] : 0; + if (!DirectoryRepository::deleteItem($directoryId, $itemId)) { + return $this->error('Значение не найдено', array(), 404); + } + + $this->audit('directory.item_deleted', $directoryId, array('item_id' => $itemId)); + return $this->success('Значение удалено', array( + 'redirect' => $this->base() . '/directories?id=' . $directoryId, + )); + } + + protected function saveDirectory($id, $message, $action) + { + try { + $directory = DirectoryRepository::save($id, Request::postAll()); + $this->audit($action, $directory['id'], array('code' => $directory['code'])); + return $this->success($message, array( + 'data' => $directory, + 'redirect' => $this->base() . '/directories?id=' . $directory['id'], + )); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + protected function saveItem($directoryId, $itemId, $message, $action) + { + try { + $item = DirectoryRepository::saveItem($directoryId, $itemId, Request::postAll()); + $this->audit($action, $directoryId, array('item_id' => $item['id'], 'key' => $item['item_key'])); + return $this->success($message, array( + 'data' => $item, + 'redirect' => $this->base() . '/directories?id=' . $directoryId, + )); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + protected function manageGuard() + { + return $this->guardPermission('manage_rubrics'); + } + + protected function audit($action, $directoryId, array $meta = array()) + { + AuditLog::record($action, array( + 'actor_id' => Auth::id(), + 'target_type' => 'directory', + 'target_id' => (int) $directoryId, + 'meta' => $meta, + )); + } + } diff --git a/adminx/modules/Directories/Model.php b/adminx/modules/Directories/Model.php new file mode 100644 index 0000000..4657d8b --- /dev/null +++ b/adminx/modules/Directories/Model.php @@ -0,0 +1,55 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Directories; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\Directories\DirectoryRepository; + + /** Admin adapter over the content-domain directory repository. */ + class Model + { + public static function all($query = '') + { + $rows = DirectoryRepository::all($query); + $usage = DirectoryRepository::usageMap(); + foreach ($rows as &$row) { + $row['usage_count'] = isset($usage[$row['id']]) ? (int) $usage[$row['id']] : 0; + } + + unset($row); + return $rows; + } + + public static function stats() + { + $rows = DirectoryRepository::all(); + $stats = array('total' => count($rows), 'active' => 0, 'values' => 0); + foreach ($rows as $row) { + if ($row['is_active']) { + $stats['active']++; + } + + $stats['values'] += (int) $row['items_count']; + } + + return $stats; + } + + public static function schemaReady() + { + return DirectoryRepository::schemaReady(); + } + } diff --git a/adminx/modules/Directories/assets/directories.css b/adminx/modules/Directories/assets/directories.css new file mode 100644 index 0000000..7f7863a --- /dev/null +++ b/adminx/modules/Directories/assets/directories.css @@ -0,0 +1,150 @@ +.content-model-tabs { + margin-bottom: var(--space-5); +} +.directories-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-5); +} +.directories-stat { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-4); +} +.directories-stat b { + display: block; + font-size: 20px; + line-height: 1.1; + font-weight: 800; +} +.directories-stat div > span { + color: var(--text-secondary); + font-size: 13px; +} +.directories-panel [hidden], +#directoryDrawer [hidden], +#directoryItemDrawer [hidden] { + display: none !important; +} +.directories-filter .input-wrap { + max-width: 560px; +} +.directories-table { + min-width: 760px; +} +.directories-col-name { + width: 34%; +} +.directories-col-code { + width: 18%; +} +.directories-col-values { + width: 100px; +} +.directories-col-usage { + width: 150px; +} +.directories-col-state { + width: 120px; +} +.directories-col-actions { + width: 92px; +} +.directories-name { + display: grid; + gap: 3px; + min-width: 0; +} +.directories-name b, +.directories-name small { + overflow-wrap: anywhere; + white-space: normal; +} +.directories-name small { + color: var(--text-secondary); + font-size: 12px; +} +.directories-table tr.is-selected > td { + background: var(--blue-50); +} +.directories-values-card { + margin-top: var(--space-4); +} +.directories-head-actions, +.directories-actions { + flex-wrap: nowrap; +} +.directories-actions { + justify-content: flex-end; + width: max-content; + margin-left: auto; +} +.directories-action-values { + color: var(--blue-600); +} +.directories-action-edit { + color: var(--violet-600); +} +.directories-items-table { + min-width: 660px; + table-layout: fixed; +} +.directories-item-order { + width: 92px; +} +.directories-item-key { + width: 190px; +} +.directories-item-state { + width: 124px; +} +.directories-item-actions { + width: 104px; +} +.directories-items-table tr.is-muted { + opacity: 0.55; +} +@media (max-width: 640px) { + .directories-summary { + grid-template-columns: 1fr; + } + .directories-values-card .rubrics-section-head { + align-items: stretch; + flex-direction: column; + } + .directories-head-actions { + justify-content: flex-end; + width: 100%; + } + .directories-table tbody tr { + grid-template-columns: minmax(0, 1fr) auto; + } + .directories-table .directories-cell-name, + .directories-table .directories-cell-code { + grid-column: 1 / -1; + } + .directories-table .directories-cell-code, + .directories-table .directories-cell-values, + .directories-table .directories-cell-usage, + .directories-table .directories-cell-state, + .directories-table .directories-cell-actions { + display: grid; + gap: 4px; + align-content: start; + justify-items: start; + } + .directories-table td[data-label]::before { + content: attr(data-label); + color: var(--text-secondary); + font-size: 10px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; + } + .directories-table .directories-cell-actions { + align-content: end; + justify-items: end; + } +} diff --git a/adminx/modules/Directories/assets/directories.js b/adminx/modules/Directories/assets/directories.js new file mode 100644 index 0000000..195d9dc --- /dev/null +++ b/adminx/modules/Directories/assets/directories.js @@ -0,0 +1,179 @@ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Directories = { + page: null, + directoryForm: null, + itemForm: null, + + init: function () { + this.page = document.querySelector('[data-directories-page]'); + if (!this.page) { return; } + + this.directoryForm = document.querySelector('[data-directory-form]'); + this.itemForm = document.querySelector('[data-directory-item-form]'); + var self = this; + + document.addEventListener('click', function (event) { + if (event.target.closest('[data-directory-new]')) { self.newDirectory(); return; } + var edit = event.target.closest('[data-directory-edit]'); + if (edit) { self.editDirectory(edit); return; } + if (event.target.closest('[data-directory-delete]')) { self.deleteDirectory(); return; } + if (event.target.closest('[data-directory-item-new]')) { self.newItem(); return; } + var itemEdit = event.target.closest('[data-directory-item-edit]'); + if (itemEdit) { self.editItem(itemEdit); return; } + var itemDelete = event.target.closest('[data-directory-item-delete]'); + if (itemDelete) { self.deleteItem(itemDelete); return; } + }); + + if (this.directoryForm) { + this.directoryForm.addEventListener('submit', function (event) { + event.preventDefault(); + var id = self.directoryForm.elements.id.value; + self.submit(self.base() + '/directories' + (id ? '/' + id : ''), self.directoryForm); + }); + } + + if (this.itemForm) { + this.itemForm.addEventListener('submit', function (event) { + event.preventDefault(); + var directoryId = self.itemForm.getAttribute('data-directory'); + var itemId = self.itemForm.elements.id.value; + self.submit(self.base() + '/directories/' + directoryId + '/items' + (itemId ? '/' + itemId : ''), self.itemForm); + }); + } + }, + + base: function () { + return this.page.getAttribute('data-base') || ''; + }, + + newDirectory: function () { + if (!this.directoryForm) { return; } + this.directoryForm.reset(); + this.directoryForm.elements.id.value = ''; + this.directoryForm.elements.is_active.checked = true; + this.directoryForm.setAttribute('data-usage', '0'); + this.directoryForm.querySelector('[data-directory-delete]').hidden = true; + document.querySelector('[data-directory-form-title]').textContent = 'Новый справочник'; + Adminx.Drawer.open('directoryDrawer'); + }, + + editDirectory: function (button) { + if (!this.directoryForm) { return; } + this.directoryForm.reset(); + this.directoryForm.elements.id.value = button.getAttribute('data-id') || ''; + this.directoryForm.elements.name.value = button.getAttribute('data-name') || ''; + this.directoryForm.elements.code.value = button.getAttribute('data-code') || ''; + this.directoryForm.elements.description.value = button.getAttribute('data-description') || ''; + this.directoryForm.elements.is_active.checked = button.getAttribute('data-active') === '1'; + this.directoryForm.setAttribute('data-usage', button.getAttribute('data-usage') || '0'); + this.directoryForm.querySelector('[data-directory-delete]').hidden = false; + document.querySelector('[data-directory-form-title]').textContent = 'Настройки справочника'; + Adminx.Drawer.open('directoryDrawer'); + }, + + deleteDirectory: function () { + if (!this.directoryForm) { return; } + var self = this; + var id = this.directoryForm.elements.id.value; + var name = this.directoryForm.elements.name.value; + var usage = parseInt(this.directoryForm.getAttribute('data-usage'), 10) || 0; + Adminx.Confirm.open({ + kind: usage > 0 ? 'warning' : 'danger', + title: usage > 0 ? 'Справочник используется' : 'Удалить справочник?', + message: usage > 0 + ? 'Он подключён к полям: ' + usage + '. Сначала выберите для них другой источник значений.' + : '«' + name + '» и все его значения будут удалены без восстановления.', + confirmLabel: usage > 0 ? 'Понятно' : 'Удалить', + confirmClass: usage > 0 ? 'btn-secondary' : 'btn-danger', + onConfirm: function () { + if (usage > 0) { return; } + var data = new FormData(); + data.append('_csrf', self.csrf()); + self.submit(self.base() + '/directories/' + id + '/delete', data); + } + }); + }, + + newItem: function () { + if (!this.itemForm) { return; } + this.itemForm.reset(); + this.itemForm.elements.id.value = ''; + this.itemForm.elements.sort_order.value = '0'; + this.itemForm.elements.is_active.checked = true; + document.querySelector('[data-directory-item-title]').textContent = 'Новое значение'; + Adminx.Drawer.open('directoryItemDrawer'); + }, + + editItem: function (button) { + if (!this.itemForm) { return; } + this.itemForm.reset(); + this.itemForm.elements.id.value = button.getAttribute('data-id') || ''; + this.itemForm.elements.item_key.value = button.getAttribute('data-key') || ''; + this.itemForm.elements.label.value = button.getAttribute('data-label') || ''; + this.itemForm.elements.sort_order.value = button.getAttribute('data-order') || '0'; + this.itemForm.elements.is_active.checked = button.getAttribute('data-active') === '1'; + document.querySelector('[data-directory-item-title]').textContent = 'Изменить значение'; + Adminx.Drawer.open('directoryItemDrawer'); + }, + + deleteItem: function (button) { + var self = this; + var itemId = button.getAttribute('data-directory-item-delete'); + var directoryId = this.itemForm ? this.itemForm.getAttribute('data-directory') : ''; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить значение?', + message: '«' + (button.getAttribute('data-label') || '') + '» исчезнет из выбора. Уже сохранённые документы сохранят ключ.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { + var data = new FormData(); + data.append('_csrf', self.csrf()); + self.submit(self.base() + '/directories/' + directoryId + '/items/' + itemId + '/delete', data); + } + }); + }, + + submit: function (url, body) { + var self = this; + Adminx.Loader.show(); + var data = body instanceof FormData ? body : new FormData(body); + fetch(url, { + method: 'POST', + body: data, + credentials: 'same-origin', + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } + }).then(function (response) { + return response.json().then(function (payload) { + if (!response.ok || !payload.success) { + throw new Error(payload.message || 'Не удалось сохранить данные'); + } + + return payload; + }); + }).then(function (payload) { + Adminx.Toast.show(payload.message || 'Сохранено', 'success'); + window.location.href = payload.redirect || window.location.href; + }).catch(function (error) { + Adminx.Toast.show(error.message || 'Ошибка запроса', 'danger'); + }).finally(function () { + Adminx.Loader.hide(); + }); + }, + + csrf: function () { + var input = document.querySelector('[data-directory-form] [name="_csrf"]'); + return input ? input.value : ''; + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Directories.init(); }); + } else { + Adminx.Directories.init(); + } +})(window, document); diff --git a/adminx/modules/Directories/migrations/001_create_directories.sql b/adminx/modules/Directories/migrations/001_create_directories.sql new file mode 100644 index 0000000..7d4a6c2 --- /dev/null +++ b/adminx/modules/Directories/migrations/001_create_directories.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS `{{content_prefix}}_directories` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `code` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `name` VARCHAR(190) NOT NULL, + `description` TEXT NULL, + `settings_json` TEXT NULL, + `is_active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_directory_code` (`code`), + KEY `idx_directory_active_name` (`is_active`, `name`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `{{content_prefix}}_directory_items` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `directory_id` INT UNSIGNED NOT NULL, + `item_key` VARCHAR(120) NOT NULL, + `label` VARCHAR(255) NOT NULL, + `sort_order` INT UNSIGNED NOT NULL DEFAULT 0, + `is_active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1, + `parent_id` INT UNSIGNED NOT NULL DEFAULT 0, + `data_json` TEXT NULL, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_directory_item_key` (`directory_id`, `item_key`(120)), + KEY `idx_directory_item_order` (`directory_id`, `sort_order`, `id`), + KEY `idx_directory_item_parent` (`directory_id`, `parent_id`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Directories/module.php b/adminx/modules/Directories/module.php new file mode 100644 index 0000000..1dd8f1f --- /dev/null +++ b/adminx/modules/Directories/module.php @@ -0,0 +1,40 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'directories', + 'name' => 'Справочники', + 'version' => '0.2.0', + 'requires' => array('rubrics'), + + 'routes' => array( + array('GET', '/directories', array(\App\Adminx\Directories\Controller::class, 'index'), array('permission' => 'view_rubrics')), + array('POST', '/directories', array(\App\Adminx\Directories\Controller::class, 'store'), array('permission' => 'manage_rubrics')), + array('POST', '/directories/{id}', array(\App\Adminx\Directories\Controller::class, 'update'), array('permission' => 'manage_rubrics')), + array('POST', '/directories/{id}/delete', array(\App\Adminx\Directories\Controller::class, 'delete'), array('permission' => 'manage_rubrics')), + array('POST', '/directories/{id}/items', array(\App\Adminx\Directories\Controller::class, 'storeItem'), array('permission' => 'manage_rubrics')), + array('POST', '/directories/{id}/items/{item}', array(\App\Adminx\Directories\Controller::class, 'updateItem'), array('permission' => 'manage_rubrics')), + array('POST', '/directories/{id}/items/{item}/delete', array(\App\Adminx\Directories\Controller::class, 'deleteItem'), array('permission' => 'manage_rubrics')), + ), + + 'migrations' => array( + array('id' => '001_create_directories', 'file' => 'migrations/001_create_directories.sql'), + ), + + 'view_globals' => array( + 'module_code' => 'directories', + ), + ); diff --git a/adminx/modules/Directories/view/index.twig b/adminx/modules/Directories/view/index.twig new file mode 100644 index 0000000..c63ff1c --- /dev/null +++ b/adminx/modules/Directories/view/index.twig @@ -0,0 +1,212 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Справочники{% endblock %} +{% block content %} + + + + + +{% if not schema_ready %} +
+ +
+ Схема справочников ещё не готова +

Примените миграции ядра в разделе «База данных → Миграции».

+
+
+{% endif %} + +
+
{{ stats.total }}Справочников
+
{{ stats.values }}Значений
+
+ +{% set content_model_section = 'directories' %} +{% include '@rubrics/_content_tabs.twig' %} + +
+
+
+
+
Поля рубрик
+

Общие справочники

+

Один список можно подключить к нескольким полям и менять централизованно.

+
+
+ +
+
+
+ +
+

Справочники

+

{{ directories|length }} в текущем фильтре, {{ stats.total }} всего.

+
+
+ {{ directories|length }} +
+
+
+
+ + {% if filters.q %}Сбросить{% endif %} +
+
+
+ +
+
+
+ +
+

Список справочников

+

Ключ хранится в документе, подпись можно менять без перезаписи документов.

+
+
+
+
+ + + + + + + + + + + + {% for item in directories %} + + + + + + + + + {% else %} + + {% endfor %} + +
НазваниеКодЗначенияИспользуетсяСостояние
+
+ {{ item.name }} + {% if item.description %}{{ item.description }}{% endif %} +
+
{{ item.code }}{{ item.items_count }}{% if item.usage_count %}{{ item.usage_count }} полей{% else %}Не подключён{% endif %}{% if item.is_active %}активен{% else %}выключен{% endif %} +
+ + {% if can_manage %} + + {% endif %} +
+
Справочников пока нетСоздайте общий список значений для полей.
+
+
+ + {% if selected %} +
+
+
+ +
+

Значения: {{ selected.name }}

+

{{ selected.code }} · {{ selected.usage_count }} полей · {{ items|length }} значений

+
+
+ {% if can_manage %} +
+ + +
+ {% endif %} +
+
+ + + + + {% for value in items %} + + + + + + + + {% else %} + + {% endfor %} + +
ПорядокКлючПодписьСостояние
{{ value.sort_order }}{{ value.item_key }}{{ value.label }}{% if value.is_active %}активно{% else %}выключено{% endif %}
+ {% if can_manage %} + + + {% endif %} +
Значений пока нетДобавьте первую пару «ключ → подпись».
+
+
+ {% endif %} +
+ +{% if can_manage %} + + +{% if selected %} + +{% endif %} +{% endif %} + +{% endblock %} diff --git a/adminx/modules/Documents/Controller.php b/adminx/modules/Documents/Controller.php index 4534f03..e100298 100644 --- a/adminx/modules/Documents/Controller.php +++ b/adminx/modules/Documents/Controller.php @@ -21,6 +21,7 @@ use App\Common\AuditLog; use App\Common\Auth; use App\Common\Controller as BaseController; + use App\Adminx\Support\BulkActionExecutor; use App\Common\ErrorReport; use App\Common\Permission; use App\Adminx\Support\CodeEditor; @@ -36,6 +37,7 @@ use App\Content\Documents\DocumentSnapshotStore; use App\Content\Documents\FieldTemplateManifest; use App\Content\Documents\RubricSchemaBuilder; + use App\Content\Fields\DocumentRelationIndex; use App\Frontend\DocumentRevisionPreview; use App\Helpers\Request; use DB; @@ -174,7 +176,11 @@ AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); - return $this->render('@documents/api.twig', array('tokens' => ApiTokenRepository::all(), 'can_manage_api' => true)); + $tokens = array_values(array_filter(ApiTokenRepository::all(), function ($token) { + return ApiTokenRepository::hasScope($token, 'documents:read') + || ApiTokenRepository::hasScope($token, 'documents:write'); + })); + return $this->render('@documents/api.twig', array('tokens' => $tokens, 'can_manage_api' => true)); } public function issueApiToken(array $params = array()) @@ -240,6 +246,7 @@ 'can_manage' => true, 'quick_edit' => Request::getBool('quick_edit', false), 'actor_id' => Auth::id(), + 'document_relations' => DocumentRelationIndex::describe((int) $item['Id']), )); } @@ -357,27 +364,31 @@ public function bulk(array $params = array()) { if (($err = $this->guard()) !== null) { return $err; } - $postedIds = Request::post('ids', array()); - $ids = is_array($postedIds) ? array_values(array_unique(array_map('intval', $postedIds))) : array(); - $ids = array_values(array_filter($ids, function ($id) { return $id > 0; })); - if (empty($ids) || count($ids) > 200) { return $this->error('Выберите от 1 до 200 документов', array(), 422); } $action = Request::postStr('action', ''); - if (!in_array($action, array('publish', 'unpublish', 'delete', 'restore', 'purge'), true)) { return $this->error('Неизвестное пакетное действие', array(), 422); } - $done = 0; $errors = array(); - foreach ($ids as $id) { - try { + $handler = function ($id) use ($action) { $doc = Model::one($id); - if (!$doc) { continue; } + if (!$doc) { return false; } if ($action === 'publish' && empty($doc['document_status'])) { Model::toggleStatus($id); } elseif ($action === 'unpublish' && !empty($doc['document_status'])) { Model::toggleStatus($id); } elseif ($action === 'delete') { Model::delete($id); } elseif ($action === 'restore') { Model::restore($id); } elseif ($action === 'purge') { Model::purge($id); } - $done++; - } catch (\Throwable $e) { $errors[] = '#' . $id . ': ' . $e->getMessage(); } + return true; + }; + + try { + $result = BulkActionExecutor::execute($action, Request::post('ids', array()), array( + 'publish' => $handler, + 'unpublish' => $handler, + 'delete' => $handler, + 'restore' => $handler, + 'purge' => $handler, + ), 200); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), array(), 422); } - return $this->success('Пакетное действие выполнено', array('data' => array('done' => $done, 'errors' => $errors))); + return $this->success('Пакетное действие выполнено', array('data' => $result)); } public function store(array $params = array()) @@ -389,7 +400,9 @@ $input = Model::prepareAliasInput(Request::postAll(), 0); $fieldValues = isset($input['fields']) && is_array($input['fields']) ? $input['fields'] : array(); $rubricId = isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0; - $fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, 0); + $fieldGroups = Model::fieldsForRubric($rubricId, 0); + $fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, 0, $fieldGroups); + $fieldValues = Model::computedFieldValues($rubricId, $fieldValues, $input, 0, $fieldGroups); $input['fields'] = $fieldValues; try { DocumentSaveEvents::before('create', 'adminx', 0, $rubricId, Auth::id(), $input, $fieldValues); @@ -400,11 +413,12 @@ return $this->error('Код рубрики остановил создание документа', array('rubric_code_start' => $e->getMessage()), 422); } - $fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, 0); + $fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, 0, $fieldGroups); + $fieldValues = Model::computedFieldValues($rubricId, $fieldValues, $input, 0, $fieldGroups); $input['fields'] = $fieldValues; $input = Model::prepareAliasInput($input, 0); $errors = $this->validate($input, 0); - $errors = array_merge($errors, Model::validateFieldValues($rubricId, $fieldValues, 0)); + $errors = array_merge($errors, Model::validateFieldValues($rubricId, $fieldValues, 0, $fieldGroups)); if (!empty($errors)) { return $this->error('Проверьте поля документа', $errors, 422); } @@ -428,7 +442,8 @@ Model::save($id, $input, Auth::id()); } - Model::saveFields($id, $fieldValues); + Model::saveFields($id, $fieldValues, $fieldGroups); + DocumentSaveEvents::persisted('create', 'adminx', $id, $rubricId, Auth::id(), $input, $fieldValues); DB::commit(); $mediaFinalization->commit(); } catch (DocumentHookException $e) { @@ -490,13 +505,15 @@ $input = Model::prepareAliasInput(Request::postAll(), $id); $rubricId = $document ? (int) $document['rubric_id'] : (isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0); $fieldValues = isset($input['fields']) && is_array($input['fields']) ? $input['fields'] : array(); - $fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, $id); + $fieldGroups = Model::fieldsForRubric($rubricId, $id); + $fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, $id, $fieldGroups); + $fieldValues = Model::computedFieldValues($rubricId, $fieldValues, $input, $id, $fieldGroups); $input['fields'] = $fieldValues; $errors = $this->validate($input, $id); - $errors = array_merge($errors, Model::validateFieldValues($rubricId, $fieldValues, $id)); + $errors = array_merge($errors, Model::validateFieldValues($rubricId, $fieldValues, $id, $fieldGroups)); return $this->success('Данные сформированы без сохранения', array('data' => array( - 'payload' => Model::previewPayload($id, $input, $fieldValues, Auth::id()), + 'payload' => Model::previewPayload($id, $input, $fieldValues, Auth::id(), $fieldGroups), 'validation_errors' => $errors, ))); } @@ -520,7 +537,9 @@ } $fieldValues = isset($input['fields']) && is_array($input['fields']) ? $input['fields'] : array(); - $fieldValues = Model::conditionalFieldValues((int) $doc['rubric_id'], $fieldValues, $id); + $fieldGroups = Model::fieldsForRubric((int) $doc['rubric_id'], $id); + $fieldValues = Model::conditionalFieldValues((int) $doc['rubric_id'], $fieldValues, $id, $fieldGroups); + $fieldValues = Model::computedFieldValues((int) $doc['rubric_id'], $fieldValues, $input, $id, $fieldGroups); $input['fields'] = $fieldValues; try { DocumentSaveEvents::before('update', 'adminx', $id, (int) $doc['rubric_id'], Auth::id(), $input, $fieldValues, $doc); @@ -531,10 +550,11 @@ return $this->error('Код рубрики остановил сохранение документа', array('rubric_code_start' => $e->getMessage()), 422); } - $fieldValues = Model::conditionalFieldValues((int) $doc['rubric_id'], $fieldValues, $id); + $fieldValues = Model::conditionalFieldValues((int) $doc['rubric_id'], $fieldValues, $id, $fieldGroups); + $fieldValues = Model::computedFieldValues((int) $doc['rubric_id'], $fieldValues, $input, $id, $fieldGroups); $input['fields'] = $fieldValues; $errors = $this->validate($input, $id); - $errors = array_merge($errors, Model::validateFieldValues((int) $doc['rubric_id'], $fieldValues, $id)); + $errors = array_merge($errors, Model::validateFieldValues((int) $doc['rubric_id'], $fieldValues, $id, $fieldGroups)); if (!empty($errors)) { return $this->error('Проверьте поля документа', $errors, 422); } @@ -559,7 +579,8 @@ Model::save($id, $input, Auth::id()); } - Model::saveFields($id, $fieldValues); + Model::saveFields($id, $fieldValues, $fieldGroups); + DocumentSaveEvents::persisted('update', 'adminx', $id, (int) $doc['rubric_id'], Auth::id(), $input, $fieldValues, $doc); DB::commit(); $mediaFinalization->commit(); } catch (EditConflict $e) { diff --git a/adminx/modules/Documents/Model.php b/adminx/modules/Documents/Model.php index 9cb7336..fa74246 100644 --- a/adminx/modules/Documents/Model.php +++ b/adminx/modules/Documents/Model.php @@ -32,10 +32,12 @@ use App\Content\Documents\DocumentAliasTemplate; use App\Content\Documents\DocumentMediaDraft; use App\Content\Documents\DocumentMediaPath; + use App\Content\Documents\DocumentSearch; use App\Common\Settings; use App\Common\SystemTables; use App\Common\Lifecycle; use App\Common\DatabaseSchema; + use App\Adminx\Support\AdminLocale; use App\Adminx\Rubrics\FieldAdminEditors; use App\Adminx\Catalog\Model as CatalogModel; @@ -63,26 +65,29 @@ $offset = ($page - 1) * $limit; $where = ' WHERE 1=1'; $args = array(); + $score = '0'; + $scoreArgs = array(); $rubricId = isset($filters['rubric_id']) ? (int) $filters['rubric_id'] : 0; $q = trim(isset($filters['q']) ? (string) $filters['q'] : ''); $field = self::normalizeSearchField(isset($filters['field']) ? (string) $filters['field'] : '', $rubricId); if ($q !== '') { if ($field !== '') { + $criteria = DocumentSearch::valueCriteria($q, array('df.field_value', 'dft.field_value')); // Поиск по значению выбранного поля рубрики (напр. «Артикул»). $where .= ' AND EXISTS (SELECT 1 FROM ' . self::docFieldsTable() . ' df' . ' INNER JOIN ' . self::fieldsTable() . ' rf ON rf.Id = df.rubric_field_id' . ' LEFT JOIN ' . self::docFieldsTextTable() . ' dft ON dft.document_id = df.document_id AND dft.rubric_field_id = df.rubric_field_id' . ' WHERE df.document_id = d.Id AND rf.rubric_field_title = %s' - . ' AND (df.field_value LIKE %ss OR dft.field_value LIKE %ss))'; + . ' AND ' . $criteria['where'] . ')'; $args[] = $field; - $args[] = $q; - $args[] = $q; + $args = array_merge($args, $criteria['where_args']); } else { - $where .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id = %i)'; - $args[] = $q; - $args[] = $q; - $args[] = (int) $q; + $criteria = DocumentSearch::criteria($q, 'd'); + $where .= ' AND ' . $criteria['where']; + $args = array_merge($args, $criteria['where_args']); + $score = $criteria['score']; + $scoreArgs = $criteria['score_args']; } } @@ -105,12 +110,16 @@ $countArgs = array_merge(array('SELECT COUNT(*) FROM ' . self::documentsTable() . ' d' . $where), $args); $total = (int) call_user_func_array(array('DB', 'query'), $countArgs)->getValue(); - $sql = 'SELECT d.*, r.rubric_title, r.rubric_alias' + $sql = 'SELECT d.*, r.rubric_title, r.rubric_alias,(' . $score . ') AS search_relevance' . ' FROM ' . self::documentsTable() . ' d' . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' . $where - . ' ORDER BY d.document_changed DESC, d.Id DESC LIMIT ' . (int) $limit . ' OFFSET ' . (int) $offset; - $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + . ' ORDER BY search_relevance DESC,d.document_changed DESC,d.Id DESC' + . ' LIMIT ' . (int) $limit . ' OFFSET ' . (int) $offset; + $rows = call_user_func_array( + array('DB', 'query'), + array_merge(array($sql), $scoreArgs, $args) + )->getAll(); $items = array(); foreach ($rows as $row) { @@ -321,7 +330,7 @@ * Валидация значений полей документа по JSON-настройкам (rubric_field_settings.rules). * Возвращает ошибки в формате ['fields[]' => сообщение]. Пусто, если правил нет. */ - public static function validateFieldValues($rubricId, array $values, $documentId = 0) + public static function validateFieldValues($rubricId, array $values, $documentId = 0, $fieldGroups = null) { $rubricId = (int) $rubricId; if ($rubricId <= 0) { @@ -330,7 +339,7 @@ $fields = array(); $effectiveValues = array(); - foreach (self::fieldsForRubric($rubricId, (int) $documentId) as $group) { + foreach (self::resolveFieldGroups($rubricId, (int) $documentId, $fieldGroups) as $group) { foreach ($group['items'] as $field) { $fields[] = $field; $effectiveValues[(int) $field['Id']] = isset($field['field_value']) ? $field['field_value'] : ''; @@ -344,8 +353,34 @@ return FieldValidator::validateValues($fields, $effectiveValues, self::rubricConditionsEnabled($rubricId)); } + public static function computedFieldValues($rubricId, array $values, array $document = array(), $documentId = 0, $fieldGroups = null) + { + $definitions = array(); + $effectiveValues = array(); + foreach (self::resolveFieldGroups((int) $rubricId, (int) $documentId, $fieldGroups) as $group) { + foreach ($group['items'] as $field) { + $fieldId = (int) $field['Id']; + $definitions[$fieldId] = $field; + $effectiveValues[$fieldId] = isset($field['field_value']) ? $field['field_value'] : ''; + } + } + + foreach ($values as $fieldId => $value) { + $effectiveValues[(int) $fieldId] = $value; + } + + $computedValues = \App\Content\Fields\ComputedFieldEvaluator::apply($definitions, $effectiveValues, $document); + foreach ($definitions as $fieldId => $field) { + if ((string) $field['rubric_field_type'] === 'computed' && array_key_exists($fieldId, $computedValues)) { + $values[$fieldId] = $computedValues[$fieldId]; + } + } + + return $values; + } + /** Remove values of fields hidden or locked by rubric form conditions. */ - public static function conditionalFieldValues($rubricId, array $values, $documentId = 0) + public static function conditionalFieldValues($rubricId, array $values, $documentId = 0, $fieldGroups = null) { if (!self::rubricConditionsEnabled((int) $rubricId)) { return $values; @@ -355,7 +390,7 @@ $fieldMap = array(); $storedValues = array(); $effectiveValues = array(); - foreach (self::fieldsForRubric((int) $rubricId, (int) $documentId) as $group) { + foreach (self::resolveFieldGroups((int) $rubricId, (int) $documentId, $fieldGroups) as $group) { foreach ($group['items'] as $field) { $fields[] = $field; $fieldId = (int) $field['Id']; @@ -406,7 +441,7 @@ return array('raw' => $value); } - public static function saveFields($documentId, array $values) + public static function saveFields($documentId, array $values, $fieldGroups = null) { $documentId = (int) $documentId; $doc = self::one($documentId); @@ -414,8 +449,9 @@ throw new \RuntimeException('Документ не найден'); } - $values = self::conditionalFieldValues((int) $doc['rubric_id'], $values, $documentId); - $fields = self::fieldsForRubric((int) $doc['rubric_id'], $documentId); + $fields = self::resolveFieldGroups((int) $doc['rubric_id'], $documentId, $fieldGroups); + $values = self::conditionalFieldValues((int) $doc['rubric_id'], $values, $documentId, $fields); + $values = self::computedFieldValues((int) $doc['rubric_id'], $values, $doc, $documentId, $fields); $catalogAllowed = array(); $catalogFields = array(); foreach ($fields as $group) { @@ -492,14 +528,16 @@ throw new \RuntimeException('Документ не найден'); } - $values = self::conditionalFieldValues((int) $doc['rubric_id'], $values, $documentId); - $errors = self::validateFieldValues((int) $doc['rubric_id'], $values, $documentId); + $fieldGroups = self::fieldsForRubric((int) $doc['rubric_id'], $documentId); + $values = self::conditionalFieldValues((int) $doc['rubric_id'], $values, $documentId, $fieldGroups); + $values = self::computedFieldValues((int) $doc['rubric_id'], $values, $doc, $documentId, $fieldGroups); + $errors = self::validateFieldValues((int) $doc['rubric_id'], $values, $documentId, $fieldGroups); if ($errors) { throw new \RuntimeException('Поля документа не прошли проверку: ' . implode('; ', array_values($errors))); } $fieldMap = array(); - foreach (self::fieldsForRubric((int) $doc['rubric_id'], $documentId) as $group) { + foreach ($fieldGroups as $group) { foreach ($group['items'] as $field) { $fieldMap[(int) $field['Id']] = $field; } @@ -683,7 +721,7 @@ ); } - public static function previewPayload($id, array $input, array $values, $authorId) + public static function previewPayload($id, array $input, array $values, $authorId, $fieldGroups = null) { $id = (int) $id; $existing = $id > 0 ? self::one($id) : null; @@ -716,7 +754,7 @@ $fields = array(); $skippedFields = array(); - $fieldRows = self::fieldsForRubric($rubricId, $id); + $fieldRows = self::resolveFieldGroups($rubricId, $id, $fieldGroups); $flatFields = array(); foreach ($fieldRows as $group) { foreach ($group['items'] as $field) { @@ -775,6 +813,13 @@ ); } + protected static function resolveFieldGroups($rubricId, $documentId, $fieldGroups) + { + return is_array($fieldGroups) + ? $fieldGroups + : self::fieldsForRubric((int) $rubricId, (int) $documentId); + } + public static function delete($id) { $id = (int) $id; @@ -903,17 +948,37 @@ throw new \RuntimeException('Сначала переместите документ в корзину'); } - DB::Delete(self::docFieldsTextTable(), 'document_id = %i', $id); - DB::Delete(self::docFieldsTable(), 'document_id = %i', $id); - DB::Delete(self::revisionsTable(), 'doc_id = %i', $id); - DB::Delete(self::aliasHistoryTable(), 'document_id = %i', $id); - DB::Delete(self::keywordsTable(), 'document_id = %i', $id); - DB::Delete(self::tagsTable(), 'document_id = %i', $id); - DB::Delete(self::remarksTable(), 'document_id = %i', $id); - DB::Delete(self::viewCountTable(), 'document_id = %i', $id); - DB::Delete(self::documentsTable(), 'Id = %i', $id); + $ownsTransaction = !DB::$transaction_in_progress; + if ($ownsTransaction) { DB::startTransaction(); } + try { + DB::Delete(self::docFieldsTextTable(), 'document_id = %i', $id); + DB::Delete(self::docFieldsTable(), 'document_id = %i', $id); + DB::Delete(self::revisionsTable(), 'doc_id = %i', $id); + DB::Delete(self::aliasHistoryTable(), 'document_id = %i', $id); + DB::Delete(self::keywordsTable(), 'document_id = %i', $id); + DB::Delete(self::tagsTable(), 'document_id = %i', $id); + DB::Delete(self::remarksTable(), 'document_id = %i', $id); + DB::Delete(self::viewCountTable(), 'document_id = %i', $id); + \App\Content\Fields\DocumentRelationIndex::removeDocument($id); + DB::Delete(self::documentsTable(), 'Id = %i', $id); + if ($ownsTransaction) { DB::commit(); } + } catch (\Throwable $e) { + if ($ownsTransaction) { DB::rollback(); } + throw $e; + } + self::clearDocumentCache($id); CatalogModel::reindexDocument($id); + Lifecycle::event( + 'content.document.deleted', + 'document', + 'deleted', + $id, + array(), + true, + array('hard_delete' => true), + 'adminx_documents' + ); return true; } @@ -1262,45 +1327,7 @@ public static function documentPicker($q, $rubricId = 0, $limit = 20) { - $limit = max(1, min(50, (int) $limit)); - $sql = 'SELECT d.Id, d.rubric_id, d.document_title, d.document_alias, r.rubric_title' - . ' FROM ' . self::documentsTable() . ' d' - . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' - . " WHERE d.document_deleted != '1'"; - $args = array(); - $q = trim((string) $q); - if ($q !== '') { - $sql .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id = %i)'; - $args[] = $q; - $args[] = $q; - $args[] = (int) $q; - } - - $rubricIds = array(); - foreach (explode(',', (string) $rubricId) as $r) { - $r = (int) trim($r); - if ($r > 0) { $rubricIds[] = $r; } - } - - if (!empty($rubricIds)) { - $rubricIds = array_values(array_unique($rubricIds)); - $sql .= ' AND d.rubric_id IN (' . implode(',', array_map('intval', $rubricIds)) . ')'; - } - - $sql .= ' ORDER BY d.document_changed DESC, d.Id DESC LIMIT ' . (int) $limit; - $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); - $out = array(); - foreach ($rows as $row) { - $out[] = array( - 'id' => (int) $row['Id'], - 'rubric_id' => (int) $row['rubric_id'], - 'title' => self::decode($row['document_title']), - 'alias' => (string) $row['document_alias'], - 'rubric_title' => self::decode(isset($row['rubric_title']) ? $row['rubric_title'] : ''), - ); - } - - return $out; + return (new \App\Content\Documents\DocumentPickerRepository())->search($q, $rubricId, $limit); } /** Existing keywords/tags for the searchable tag inputs in document editor. */ @@ -2185,14 +2212,17 @@ } } + $emptyLabel = AdminLocale::translateMarkup('Разделы не выбраны.'); + $addLabel = AdminLocale::translateMarkup('Добавить раздел'); + $searchLabel = AdminLocale::translateMarkup('Найти раздел'); $html = '
' . '' . '
' . $tokens . '
' - . '

Разделы не выбраны.

' + . '

' . $emptyLabel . '

' . '
'; return $html; @@ -2213,18 +2243,21 @@ protected static function catalogTokenHtml($fieldId, $catalogId, $name, $fields) { + $removeLabel = AdminLocale::translateMarkup('Убрать раздел'); return '' . '' . '' . self::e($name) . '' . '' - . '' + . '' . ''; } protected static function renderCatalogNodes(array $items, $fieldId, array $selected) { $html = ''; + $documentLabel = AdminLocale::translateMarkup('документ #'); + $hiddenLabel = AdminLocale::translateMarkup('скрыт'); foreach ($items as $item) { $fields = implode(',', isset($item['fields_use']) ? $item['fields_use'] : array()); $isSelected = isset($selected[$item['id']]); @@ -2232,8 +2265,8 @@ . ''; + . ($item['document_id'] > 0 ? ' · ' . $documentLabel . (int) $item['document_id'] : '') . '' + . ((int) $item['status'] === 1 ? '' : '' . $hiddenLabel . '') . ''; if (!empty($item['children'])) { $html .= '
    ' . self::renderCatalogNodes($item['children'], $fieldId, $selected) . '
'; } $html .= ''; } diff --git a/adminx/modules/Documents/assets/documents.css b/adminx/modules/Documents/assets/documents.css index f937d6c..d468d13 100644 --- a/adminx/modules/Documents/assets/documents.css +++ b/adminx/modules/Documents/assets/documents.css @@ -19,7 +19,6 @@ font-size: 20px; line-height: 1.1; font-weight: 800; - font-variant-numeric: tabular-nums; } .documents-stat span { font-size: 13px; @@ -124,7 +123,6 @@ } .documents-term-option .co-sub { flex: 0 0 auto; - font-variant-numeric: tabular-nums; } .documents-term-status { display: flex; @@ -512,26 +510,27 @@ width: 7ch; } .documents-col-title { - width: 32%; + width: auto; } .documents-col-check { width: 44px; } .documents-col-rubric { - width: 22%; + width: 170px; } .documents-col-state { - width: 18%; + width: 144px; } .documents-col-date { - width: 18%; + width: 152px; } .documents-col-actions { - width: 184px; + width: 164px; } .documents-name, .documents-meta { display: grid; + grid-template-columns: minmax(0, 1fr); gap: 3px; min-width: 0; } @@ -542,6 +541,15 @@ text-overflow: ellipsis; white-space: nowrap; } +.documents-cell-title .documents-name b { + display: block; + max-width: 100%; + overflow: hidden; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; + line-height: 1.35; +} .documents-title-meta { display: flex; flex-wrap: wrap; @@ -558,7 +566,10 @@ min-width: 0; } .documents-title-meta .mono { + overflow: hidden; color: var(--text-muted); + text-overflow: ellipsis; + white-space: nowrap; } .documents-search-flag { display: inline-flex; @@ -591,6 +602,9 @@ .documents-actions .btn { text-decoration: none; } +.documents-cell-actions .documents-actions { + justify-content: flex-end; +} .documents-action-edit { color: var(--blue-600); } @@ -1184,20 +1198,43 @@ .documents-edit-header h1 { text-wrap: balance; } -.documents-editor-modes { - flex: 0 0 auto; +.documents-workspace-navigation { + margin-bottom: 16px; } -.documents-editor-modes .segmented-item { - min-height: 36px; - gap: 6px; +.documents-workspace-tabs { + overflow: visible; } -.documents-edit-form[data-editor-mode="quick"] [data-editor-level="normal"], -.documents-edit-form[data-editor-mode="quick"] [data-editor-level="advanced"], -.documents-edit-form[data-editor-mode="normal"] [data-editor-level="advanced"] { +.documents-workspace-tabs .tab { + min-height: 40px; +} +.documents-workspace-tabs .tab[aria-disabled="true"] { + opacity: 0.45; + cursor: not-allowed; +} +.documents-workspace-mobile { display: none; } -.documents-edit-form[data-editor-mode="quick"] .documents-edit-layout { - grid-template-columns: minmax(0, 1fr); +.documents-editor-workspace [data-document-workspace-panel] { + display: none; +} +.documents-editor-workspace[data-active-panel="main"] [data-document-section="additional"], +.documents-editor-workspace[data-active-panel="additional"] [data-document-section="main"] { + display: none; +} +.documents-editor-workspace[data-active-panel="attributes"] #documentForm, +.documents-editor-workspace[data-active-panel="promotions"] #documentForm, +.documents-editor-workspace[data-active-panel="variants"] #documentForm, +.documents-editor-workspace[data-active-panel="shipping"] #documentForm { + display: none; +} +.documents-editor-workspace[data-active-panel="attributes"] [data-document-workspace-panel="attributes"], +.documents-editor-workspace[data-active-panel="promotions"] [data-document-workspace-panel="promotions"], +.documents-editor-workspace[data-active-panel="variants"] [data-document-workspace-panel="variants"], +.documents-editor-workspace[data-active-panel="shipping"] [data-document-workspace-panel="shipping"] { + display: block; +} +.documents-editor-workspace [data-document-workspace-panel] { + margin-bottom: 0; } .documents-draft-recovery, .documents-error-summary { @@ -1358,9 +1395,6 @@ .documents-field-tabs .tab[hidden] { display: none; } -.documents-field-tabs .tab-count { - font-variant-numeric: tabular-nums; -} .documents-field-tabs .tab-hint { margin-left: 2px; font-size: 14px; @@ -1602,7 +1636,6 @@ font-family: var(--font-mono, ui-monospace, "SFMono-Regular", monospace); font-size: 11px; font-weight: 500; - font-variant-numeric: tabular-nums; } .ax-attr-type { display: none; @@ -1925,7 +1958,6 @@ font-size: 11px; font-weight: 600; color: var(--text-tertiary); - font-variant-numeric: tabular-nums; } .field-count.is-over { color: var(--red-600); @@ -2029,7 +2061,6 @@ .documents-relation-empty { color: var(--text-tertiary); font-size: 12px; - font-variant-numeric: tabular-nums; } .documents-relation-empty { margin: 0; @@ -2106,7 +2137,6 @@ white-space: nowrap; color: var(--text-tertiary); font-size: 11.5px; - font-variant-numeric: tabular-nums; } .documents-relation-choice-caret { flex: 0 0 auto; @@ -2520,7 +2550,6 @@ .documents-relation-id { font-family: var(--font-mono); color: var(--text-secondary); - font-variant-numeric: tabular-nums; } .documents-relation-item b, .documents-relation-item small, @@ -2787,7 +2816,6 @@ .documents-revision-field span { color: var(--text-secondary); font-size: 12px; - font-variant-numeric: tabular-nums; } .documents-revision-field-name { display: grid; @@ -3024,6 +3052,12 @@ } } @media (max-width: 700px) { + .documents-workspace-tabs { + display: none; + } + .documents-workspace-mobile { + display: grid; + } .documents-single-media-field.documents-media-card { grid-template-columns: 1fr; } @@ -3116,7 +3150,7 @@ position: relative; display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; - grid-template-areas: "check title id" "check rubric state" "date date date" "actions actions actions"; + grid-template-areas: "check title id" "check rubric rubric" "state state state" "date date date" "actions actions actions"; gap: 9px 10px; padding: 13px 12px 11px; border-bottom: 1px solid var(--border-default); @@ -3135,6 +3169,7 @@ box-shadow: inset 3px 0 0 var(--red-500); } .documents-table .documents-row td { + width: auto; min-width: 0; padding: 0; border: 0; @@ -3527,3 +3562,62 @@ grid-template-columns: 1fr; } } +.documents-relations-section { + margin-top: var(--space-5); +} +.documents-relations-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-5); +} +.documents-relation-list { + display: grid; + align-content: start; + gap: var(--space-2); + min-width: 0; +} +.documents-relation-title, +.documents-relation-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} +.documents-relation-title { + padding-bottom: var(--space-2); +} +.documents-relation-title span { + color: var(--text-secondary); + font-size: 12px; +} +.documents-relation-row { + min-height: 48px; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + background: var(--surface-muted); + color: var(--text-primary); + text-decoration: none; +} +.documents-relation-row:hover { + background: var(--blue-50); + color: var(--blue-700); +} +.documents-relation-row > span { + display: grid; + gap: 2px; + min-width: 0; +} +.documents-relation-row b, +.documents-relation-row small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-relation-row small { + color: var(--text-secondary); +} +@media (max-width: 820px) { + .documents-relations-grid { + grid-template-columns: 1fr; + } +} diff --git a/adminx/modules/Documents/assets/documents.js b/adminx/modules/Documents/assets/documents.js index 15647b5..84d069d 100644 --- a/adminx/modules/Documents/assets/documents.js +++ b/adminx/modules/Documents/assets/documents.js @@ -22,7 +22,7 @@ currentRemarkDocumentId: 0, draftTimer: null, draftCandidate: null, - editorMode: 'normal', + workspacePanel: 'main', errorTargets: [], conditionsInitialized: false, @@ -67,8 +67,10 @@ var fieldTab = e.target.closest('[data-document-field-tab]'); if (fieldTab) { self.activateFieldGroup(fieldTab.getAttribute('data-document-field-tab')); } - var editorMode = e.target.closest('[data-document-editor-mode]'); - if (editorMode) { self.setEditorMode(editorMode.getAttribute('data-document-editor-mode'), true); } + var workspaceTab = e.target.closest('[data-document-workspace-tab]'); + if (workspaceTab && workspaceTab.getAttribute('aria-disabled') !== 'true') { self.setWorkspacePanel(workspaceTab.getAttribute('data-document-workspace-tab'), true); } + var workspaceOpen = e.target.closest('[data-document-workspace-open]'); + if (workspaceOpen) { self.setWorkspacePanel(workspaceOpen.getAttribute('data-document-workspace-open'), true); } if (e.target.closest('[data-document-draft-restore]')) { self.restoreLocalDraft(); } if (e.target.closest('[data-document-draft-discard]')) { self.discardLocalDraft(); } var errorJump = e.target.closest('[data-document-error-jump]'); @@ -201,7 +203,7 @@ this.updateAliasTemplateHint(); this.initTermInputs(); this.applyFieldConditions(); - this.initEditorMode(); + this.initWorkspace(); this.setDirty(false); window.setTimeout(function () { self.checkLocalDraft(); }, 180); window.addEventListener('beforeunload', function (e) { @@ -211,6 +213,28 @@ }); } + document.addEventListener('change', function (e) { + if (e.target.matches('[data-document-workspace-select]')) { + self.setWorkspacePanel(e.target.value, true); + } + }); + + document.addEventListener('keydown', function (e) { + var current = e.target.closest('[data-document-workspace-tab]'); + if (!current || ['ArrowLeft', 'ArrowRight', 'Home', 'End'].indexOf(e.key) === -1) { return; } + var tabs = Array.prototype.slice.call(document.querySelectorAll('[data-document-workspace-tab]')).filter(function (tab) { + return tab.getAttribute('aria-disabled') !== 'true'; + }); + if (!tabs.length) { return; } + e.preventDefault(); + var index = tabs.indexOf(current); + if (e.key === 'Home') { index = 0; } + else if (e.key === 'End') { index = tabs.length - 1; } + else if (e.key === 'ArrowLeft') { index = (index - 1 + tabs.length) % tabs.length; } + else { index = (index + 1) % tabs.length; } + self.setWorkspacePanel(tabs[index].getAttribute('data-document-workspace-tab'), true); + }); + document.addEventListener('submit', function (e) { var presetForm = e.target.closest('[data-document-preset-form]'); if (presetForm) { @@ -376,7 +400,7 @@ document.addEventListener('keydown', function (e) { if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 's' && self.form) { e.preventDefault(); - self.submit(true); + self.saveActiveWorkspace(); } }); @@ -736,6 +760,8 @@ applyFilters: function (form, push) { if (!form) { return; } + clearTimeout(this.filterTimer); + this.filterTimer = null; this.applyFilterUrl(this.filterUrl(form), push); }, @@ -1399,33 +1425,76 @@ this.setSaveState(this.submitting ? 'saving' : (this.dirty ? 'dirty' : 'saved')); }, - initEditorMode: function () { - if (!this.form) { return; } - var mode = this.form.getAttribute('data-quick-edit') === '1' ? 'quick' : 'normal'; - try { mode = window.localStorage.getItem(this.editorModeKey()) || mode; } catch (e) {} - this.setEditorMode(mode, false); + initWorkspace: function () { + if (!this.form || !document.querySelector('[data-document-workspace]')) { return; } + var panel = 'main'; + try { panel = window.localStorage.getItem(this.workspaceKey()) || panel; } catch (e) {} + this.setWorkspacePanel(panel, false); }, - editorModeKey: function () { + workspaceKey: function () { var actor = this.form ? this.form.getAttribute('data-actor-id') || '0' : '0'; var rubric = this.form ? this.form.getAttribute('data-rubric-id') || '0' : '0'; - return 'ave.adminx.document-editor-mode:' + actor + ':' + rubric; + var workspace = document.querySelector('[data-document-workspace]'); + var kind = workspace && workspace.getAttribute('data-catalog-mode') === '1' ? 'product' : 'document'; + return 'ave.adminx.document-workspace:' + actor + ':' + rubric + ':' + kind; }, - setEditorMode: function (mode, persist) { - if (!this.form) { return; } - if (['quick', 'normal', 'advanced'].indexOf(mode) === -1) { mode = 'normal'; } - this.editorMode = mode; - this.form.setAttribute('data-editor-mode', mode); - document.querySelectorAll('[data-document-editor-mode]').forEach(function (button) { - var active = button.getAttribute('data-document-editor-mode') === mode; - button.classList.toggle('is-active', active); - button.setAttribute('aria-pressed', active ? 'true' : 'false'); - }); - if (persist) { - try { window.localStorage.setItem(this.editorModeKey(), mode); } catch (e) {} + setWorkspacePanel: function (panel, persist) { + var workspace = document.querySelector('[data-document-workspace]'); + if (!workspace) { return; } + var button = workspace.querySelector('[data-document-workspace-tab="' + panel + '"]'); + if (!button || button.getAttribute('aria-disabled') === 'true') { + panel = 'main'; + button = workspace.querySelector('[data-document-workspace-tab="main"]'); } - window.setTimeout(function () { if (Adminx.CodeEditor) { Adminx.CodeEditor.refreshAll(); } }, 40); + this.workspacePanel = panel; + workspace.setAttribute('data-active-panel', panel); + workspace.querySelectorAll('[data-document-workspace-tab]').forEach(function (tab) { + var active = tab.getAttribute('data-document-workspace-tab') === panel; + tab.classList.toggle('is-active', active); + tab.setAttribute('aria-selected', active ? 'true' : 'false'); + tab.setAttribute('tabindex', active ? '0' : '-1'); + }); + var select = workspace.querySelector('[data-document-workspace-select]'); + if (select) { select.value = panel; } + if (persist) { + try { window.localStorage.setItem(this.workspaceKey(), panel); } catch (e) {} + } + window.setTimeout(function () { + if (Adminx.CodeEditor) { Adminx.CodeEditor.refreshAll(); } + if (persist && button && typeof button.focus === 'function' && document.activeElement !== select) { + button.focus({ preventScroll: true }); + } + }, 40); + }, + + workspaceForTarget: function (target) { + if (!target) { return 'main'; } + var productPanel = target.closest('[data-document-workspace-panel]'); + if (productPanel) { return productPanel.getAttribute('data-document-workspace-panel') || 'main'; } + var section = target.closest('[data-document-section]'); + return section ? section.getAttribute('data-document-section') || 'main' : 'main'; + }, + + setWorkspaceForTarget: function (target) { + this.setWorkspacePanel(this.workspaceForTarget(target), true); + }, + + saveActiveWorkspace: function () { + var workspace = document.querySelector('[data-document-workspace]'); + var panel = workspace ? workspace.getAttribute('data-active-panel') || 'main' : 'main'; + if (panel === 'attributes' || panel === 'shipping' || panel === 'promotions') { + var form = workspace.querySelector('[data-document-workspace-panel="' + panel + '"] form'); + if (form && typeof form.requestSubmit === 'function') { form.requestSubmit(); } + else if (form) { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); } + return; + } + if (panel === 'variants') { + if (Adminx.Toast) { Adminx.Toast.show('В этом разделе нет несохранённых полей', 'info'); } + return; + } + this.submit(true); }, draftKey: function () { @@ -1556,6 +1625,11 @@ promoteCreatedDocument: function (id, stayUrl) { id = parseInt(id, 10) || 0; if (!id) { return; } + var workspace = document.querySelector('[data-document-workspace]'); + if (workspace && workspace.getAttribute('data-catalog-mode') === '1') { + window.location.href = stayUrl; + return; + } window.history.replaceState({ adminxDocumentEdit: true }, '', stayUrl); document.querySelectorAll('[data-document-remarks], [data-document-aliases], [data-document-revisions], [data-document-snapshot], [data-open-drawer="documentPresetDrawer"]').forEach(function (button) { button.disabled = false; @@ -2079,6 +2153,7 @@ if (jumpPanel !== null) { self.activateFieldGroup(jumpPanel); } var firstInvalid = self.form.querySelector('.is-invalid'); if (firstInvalid) { + self.setWorkspaceForTarget(firstInvalid); window.setTimeout(function () { firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' }); var input = firstInvalid.matches('input,select,textarea') ? firstInvalid : firstInvalid.querySelector('input,select,textarea,[contenteditable="true"]'); @@ -2118,12 +2193,7 @@ jumpToError: function (index) { var target = this.errorTargets[parseInt(index, 10) || 0]; if (!target) { return; } - var level = target.closest('[data-editor-level]'); - if (level) { - var required = level.getAttribute('data-editor-level'); - if (required === 'advanced') { this.setEditorMode('advanced', true); } - else if (required === 'normal' && this.editorMode === 'quick') { this.setEditorMode('normal', true); } - } + this.setWorkspaceForTarget(target); var panel = target.closest('[data-document-field-panel]'); if (panel) { this.activateFieldGroup(panel.getAttribute('data-document-field-panel')); } window.setTimeout(function () { diff --git a/adminx/modules/Documents/assets/redirects.js b/adminx/modules/Documents/assets/redirects.js index 45654d4..51ecddd 100644 --- a/adminx/modules/Documents/assets/redirects.js +++ b/adminx/modules/Documents/assets/redirects.js @@ -6,7 +6,7 @@ filterTimer: null, filterAbort: null, filterRequest: 0, - base: function () { return window.ADMINX_BASE || '/adminx'; }, + base: function () { return window.ADMINX_BASE || Adminx.base(); }, csrf: function () { var el = document.querySelector('[data-redirect-csrf]'); return el ? el.value : ''; }, json: function (response) { return response.json().then(function (data) { if (!response.ok || data.success === false) { throw new Error(data.message || 'Ошибка запроса'); } return data; }); }, esc: function (value) { var div = document.createElement('div'); div.textContent = value == null ? '' : String(value); return div.innerHTML; }, @@ -47,6 +47,8 @@ load: function (url, push) { var self = this; + clearTimeout(this.filterTimer); + this.filterTimer = null; var requestId = ++this.filterRequest; var active = document.activeElement; var focusName = active && active.closest && active.closest('[data-redirect-filter]') ? active.getAttribute('name') : ''; diff --git a/adminx/modules/Documents/language/en/client.xml b/adminx/modules/Documents/language/en/client.xml index c0d8925..1dd420f 100644 --- a/adminx/modules/Documents/language/en/client.xml +++ b/adminx/modules/Documents/language/en/client.xml @@ -1,47 +1,290 @@ + x Add more... + The address of the document will be written into the link field. + Up to 10 characters, optional. + Token copied + <button class="btn btn-ghost btn-icon btn-sm documents-media-remove" type="button" data-document-media-remove data-tooltip="Delete" aria-label="Delete"><i class="ti ti-trash"></i></button> + field + Clear all field lines? + Parameter + Rebuild JSON snapshots? New + click to select + Failed to check alias + API token created Add a keyword + <div class="modal-body"><div class="input-wrap documents-relation-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="ID, title or alias" data-relation-search></div><div class="documents-picker-status" data-relation-status>Loading...</div><div class="documents-relation-list" data-relation-list></div></div> + JSON snapshot + to Creation preset + uh + Teaser ID/link + Internal server error. Please refresh the page and try again. + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-down data-tooltip="Below" aria-label="Below"><i class="ti ti-arrow-down"></i></button> + "><button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Select file" aria-label="Select file"><i class="ti ti- + ts + The document ID will be written in the field. + after saving Creation preset saved Failed to load list + h + h + <section class="documents-revision-system"><div class="documents-revision-subhead"><i class="ti ti-settings"></i><b>Basic settings</b><span> + [link]" data-media-key="link" value="" placeholder="Link or document" data-document-media-url data-document-picker-type="all"> + Restore + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-doc-pick data-tooltip="Select document" aria-label="Select document"><i class="ti ti-file-search"></i></button></div> + </span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="field" checked><span>All</span></label></div> + ID, name or alias + Select file + Column 3 + Redirect saved + The secret is ready to be copied. + The server returned an incorrect response. + Refresh page + Untitled + JSON snapshots reassembled: + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-up data-tooltip="Above" aria-label="Above"><i class="ti ti-arrow-up"></i></button> + Document ID + Note added + new new + Draft + Title + Revision deleted + Document title + Files uploaded + Select revision + Rebuild JSON Clear daily statistics? + Column 1 + fields + th + There are no matching files in the folder Statistics cleared + not created yet + Copy + Redirect removed + Audits + I + l + a + b + Document saved + <span class="badge badge-gray">not created</span> + Failed to read folder + Note deleted + File not selected + f + o + <div><dt>File</dt><dd class="mono"> + kg + r + . The window must be left open until completion. in the section + s All view_count lines will be removed. General document counters will remain unchanged. + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Select file" aria-label="Select file"><i class="ti ti-paperclip"></i></button> + Rebuild + <span class="badge badge-amber">needs reassembly</span> doc. + 0 B + d <div class="documents-term-status is-error"><i class="ti ti-alert-circle"></i><span>Failed to load options</span></div> + <div class="empty-state">There are no revisions yet. The first snapshot will appear after saving the document.</div> + <div><label class="documents-revision-check" aria-label="Restore + Clear the field? + Column 2 + Alias can be left empty + e + Length + f Delete " + Withdrawn + </p></div><button class="modal-close" type="button" data-relation-close aria-label="Close"><i class="ti ti-x"></i></button></div> + <span class="badge badge-green">relevant</span> URL copied There are no saved values yet + [description]" data-media-key="description" rows="2" placeholder="Description"></textarea> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-down data-tooltip="Below" aria-label="Below"><i class="ti ti-arrow-down"></i></button> + The integration will immediately lose access. It will be impossible to return this token. + Errors found: + Category template: / + , errors: + Template Failed to load statistics + API token revoked Find or add a tag + n + Open the desired folder and confirm your selection. All suitable files from it will be added to the field. + <div><dt>State</dt><dd> + <div class="empty-state">Loading...</div> + The contents of the snapshot will appear after selecting a revision. Delete + Copied + optional + The description will appear after filling out the meta description. + Documents + The document has been restored + g No matches + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-up data-tooltip="Above" aria-label="Above"><i class="ti ti-arrow-up"></i></button> + Permissions for one external integration. + The document has changed Request Error + fields Clear + Revoke + Changes saved + [title]" data-media-key="title" value="" placeholder="Image title"> + Remove + n + Added from folder: + Edit + Failed to load revisions + Meaning + Select document + <div><dt>Formed by</dt><dd> + Fields only + b + The category does not have a path template: the document alias is used from the root of the site. + Failed to generate alias + and + Height, + No image selected + <div><dt>Size</dt><dd> + JSON snapshot rebuilt + Document revisions + Heading Loading... + Remove document + Add from this folder + All lines in this field will be removed from the document after saving. + <button class="btn btn-ghost btn-icon btn-sm documents-drag-handle" type="button" data-doc-drag draggable="true" data-tooltip="Drag" aria-label="Drag"><i class="ti ti-grip-vertical"></i></button> Preset deleted + Pictures + / + document alias. The date is taken from the publication. + <div><dt>Fields</dt><dd class="mono"> + Batch rebuild stopped + <article class="documents-revision-field documents-revision-system-field"><div><label class="documents-revision-check" aria-label="Restore + Failed to get snapshot status + with + These files are already in the field + The pictures will be sequentially reassembled to + pictures + yu + [name]" data-media-key="name" value="" placeholder="File name"> + Revoke an API token? + <section class="documents-revision-content"><div class="documents-revision-subhead"><i class="ti ti-forms"></i><b>Category fields</b><span> + e from + m + w Failed to apply filters + Field Read only Click to find document Add " <div class="documents-term-status"><i class="ti ti-loader-2"></i><span>Looking for matches...</span></div> preset Delete preset + Failed to generate short alias + can be left blank + Select at least one resolution + Width, + Delete revision + <div><dt>Status</dt><dd>Loading...</dd></div> + All elements of this field will be removed from the document after saving. + Weight, + History of field values + Additionally + at + Failed to check short alias + Checking... + draft Assignment document + Add files from a folder + Revisions deleted + cm + <button class="btn btn-ghost btn-icon btn-sm documents-value-remove" type="button" data-document-value-remove data-tooltip="Delete" aria-label="Delete"><i class="ti ti-trash"></i></button> + ъ + t + </span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="document" checked><span>All</span></label></div> + The document has already been changed. Refresh the page. + <button class="documents-media-thumb" type="button" data-document-media-pick aria-label="Select file"><span><i class="ti ti- + The story is still empty Incorrect answer + Clear all field elements? Find or add a keyword in + Stay + <div class="modal-footer"><div class="mf-left documents-picker-count" data-relation-count></div><button class="btn btn-ghost" type="button" data-relation-close>Close</button></div> No document selected New redirect + Old URL + <div class="modal-header"><span class="dialog-icon info"><i class="ti ti-file-search"></i></span><div style="flex:1"><h3>Select document</h3><p class="text-secondary" style="margin-top:4px"> Add a tag + The path will be written in the document field. + A newer version is already saved on the server. Refresh the page, check the changes, and save the document again. + sch + n + Title + t + ъ + th + d + Fields only + e + h + e + Note added + n + yu + h + s + JSON snapshot rebuilt + w + o + to + not created yet + cm + The document has been restored + API token revoked + Draft + Revisions deleted + r + f + ts + m + a + Field # + Note deleted + b + I + Document saved + with + g + Revision deleted in + b + x + sch + at + can be left blank + kg Creation preset saved + B + Redirect removed + l Preset deleted + f Document # + Redirect saved + API token created + uh + and + after saving New + Template diff --git a/adminx/modules/Documents/language/en/zz-interface.xml b/adminx/modules/Documents/language/en/zz-interface.xml new file mode 100644 index 0000000..637036a --- /dev/null +++ b/adminx/modules/Documents/language/en/zz-interface.xml @@ -0,0 +1,9 @@ + + + No sections selected. + Add section + Find section + Remove section + document # + hidden + diff --git a/adminx/modules/Documents/language/ru/client.xml b/adminx/modules/Documents/language/ru/client.xml index 9f4a554..0fe7fc6 100644 --- a/adminx/modules/Documents/language/ru/client.xml +++ b/adminx/modules/Documents/language/ru/client.xml @@ -1,47 +1,290 @@ + х Добавить ещё… + В поле-ссылку будет записан адрес документа. + До 10 символов, необязательно. + Токен скопирован + <button class="btn btn-ghost btn-icon btn-sm documents-media-remove" type="button" data-document-media-remove data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button> + поле + Очистить все строки поля? + Параметр + Пересобрать JSON-снимки? Новый + нажмите, чтобы выбрать + Не удалось проверить alias + API-токен создан Добавить ключевое слово + <div class="modal-body"><div class="input-wrap documents-relation-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="ID, название или alias" data-relation-search></div><div class="documents-picker-status" data-relation-status>Загрузка...</div><div class="documents-relation-list" data-relation-list></div></div> + JSON-снимок + к Пресет создания + э + ID тизера / ссылка + Внутренняя ошибка сервера. Обновите страницу и повторите попытку. + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-down data-tooltip="Ниже" aria-label="Ниже"><i class="ti ti-arrow-down"></i></button> + "><button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Выбрать файл" aria-label="Выбрать файл"><i class="ti ti- + ц + В поле будет записан ID документа. + после сохранения Пресет создания сохранён Не удалось загрузить список + ч + з + <section class="documents-revision-system"><div class="documents-revision-subhead"><i class="ti ti-settings"></i><b>Основные настройки</b><span> + [link]" data-media-key="link" value="" placeholder="Ссылка или документ" data-document-media-url data-document-picker-type="all"> + Восстановить + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-doc-pick data-tooltip="Выбрать документ" aria-label="Выбрать документ"><i class="ti ti-file-search"></i></button></div> + </span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="field" checked><span>Все</span></label></div> + ID, название или alias + Выбрать файл + Колонка 3 + Редирект сохранён + Секрет готов к копированию. + Сервер вернул некорректный ответ. + Обновить страницу + Без названия + JSON-снимки пересобраны: + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-up data-tooltip="Выше" aria-label="Выше"><i class="ti ti-arrow-up"></i></button> + ID документа + Заметка добавлена + новый новое + Черновик + Название + Ревизия удалена + Заголовок документа + Файлы загружены + Выберите ревизию + Пересобрать JSON Очистить подневную статистику? + Колонка 1 + поля + й + В папке нет подходящих файлов Статистика очищена + ещё не создан + Копировать + Редирект удалён + Ревизии + я + л + а + ь + Документ сохранён + <span class="badge badge-gray">не создан</span> + Не удалось прочитать папку + Заметка удалена + Файл не выбран + ф + о + <div><dt>Файл</dt><dd class="mono"> + кг + р + . Окно нужно оставить открытым до завершения. в рубрике + ы Все строки view_count будут удалены. Общие счётчики документов останутся без изменений. + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Выбрать файл" aria-label="Выбрать файл"><i class="ti ti-paperclip"></i></button> + Пересобрать + <span class="badge badge-amber">нужна пересборка</span> док. + 0 Б + д <div class="documents-term-status is-error"><i class="ti ti-alert-circle"></i><span>Не удалось загрузить варианты</span></div> + <div class="empty-state">Ревизий пока нет. Первый снимок появится после сохранения документа.</div> + <div><label class="documents-revision-check" aria-label="Восстановить + Очистить поле? + Колонка 2 + Alias можно оставить пустым + ё + Длина, + ж Удалить « + Отозван + </p></div><button class="modal-close" type="button" data-relation-close aria-label="Закрыть"><i class="ti ti-x"></i></button></div> + <span class="badge badge-green">актуален</span> URL скопирован Сохранённых значений пока нет + [description]" data-media-key="description" rows="2" placeholder="Описание"></textarea> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-down data-tooltip="Ниже" aria-label="Ниже"><i class="ti ti-arrow-down"></i></button> + Интеграция сразу потеряет доступ. Вернуть этот токен будет невозможно. + Найдено ошибок: + Шаблон рубрики: / + , ошибок: + Шаблон Не удалось загрузить статистику + API-токен отозван Найти или добавить тег + н + Откройте нужную папку и подтвердите выбор. В поле добавятся все подходящие файлы из неё. + <div><dt>Состояние</dt><dd> + <div class="empty-state">Загрузка...</div> + Содержимое снимка появится после выбора ревизии. Удалить + Скопировано + необязательно + Описание появится после заполнения meta description. + Документов + Документ восстановлен + г Совпадений нет + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-up data-tooltip="Выше" aria-label="Выше"><i class="ti ti-arrow-up"></i></button> + Права доступа для одной внешней интеграции. + Документ изменился Ошибка запроса + полей Очистить + Отозвать + Изменения сохранены + [title]" data-media-key="title" value="" placeholder="Заголовок изображения"> + Убрать + п + Добавлено из папки: + Изменить + Не удалось загрузить ревизии + Значение + Выбрать документ + <div><dt>Сформирован</dt><dd> + Только поля + б + У рубрики нет шаблона пути: alias документа используется от корня сайта. + Не удалось сгенерировать alias + и + Высота, + Изображение не выбрано + <div><dt>Размер</dt><dd> + JSON-снимок пересобран + Ревизии документа + Заголовок Загрузка... + Убрать документ + Добавить из этой папки + Все строки этого поля будут удалены из документа после сохранения. + <button class="btn btn-ghost btn-icon btn-sm documents-drag-handle" type="button" data-doc-drag draggable="true" data-tooltip="Перетащить" aria-label="Перетащить"><i class="ti ti-grip-vertical"></i></button> Пресет удалён + Снимки + / + alias документа. Дата берётся из публикации. + <div><dt>Поля</dt><dd class="mono"> + Пакетная пересборка остановлена + <article class="documents-revision-field documents-revision-system-field"><div><label class="documents-revision-check" aria-label="Восстановить + Не удалось получить статус снимка + с + Эти файлы уже есть в поле + Снимки будут последовательно пересобраны для + снимков + ю + [name]" data-media-key="name" value="" placeholder="Название файла"> + Отозвать API-токен? + <section class="documents-revision-content"><div class="documents-revision-subhead"><i class="ti ti-forms"></i><b>Поля рубрики</b><span> + е из + м + ш Не удалось применить фильтры + Поле Только чтение Нажмите, чтобы найти документ Добавить « <div class="documents-term-status"><i class="ti ti-loader-2"></i><span>Ищем совпадения…</span></div> пресет Удалить пресет + Не удалось сгенерировать короткий алиас + можно оставить пустым + Выберите хотя бы одно разрешение + Ширина, + Удалить ревизию + <div><dt>Состояние</dt><dd>Загрузка...</dd></div> + Все элементы этого поля будут удалены из документа после сохранения. + Вес, + История значений полей + Дополнительно + у + Не удалось проверить короткий alias + Проверка... + черновик Документ назначения + Добавить файлы из папки + Ревизии удалены + см + <button class="btn btn-ghost btn-icon btn-sm documents-value-remove" type="button" data-document-value-remove data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button> + ъ + т + </span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="document" checked><span>Все</span></label></div> + Документ уже изменён. Обновите страницу. + <button class="documents-media-thumb" type="button" data-document-media-pick aria-label="Выбрать файл"><span><i class="ti ti- + История пока пустая Некорректный ответ + Очистить все элементы поля? Найти или добавить ключевое слово в + Остаться + <div class="modal-footer"><div class="mf-left documents-picker-count" data-relation-count></div><button class="btn btn-ghost" type="button" data-relation-close>Закрыть</button></div> Документ не выбран Новый редирект + Старый URL + <div class="modal-header"><span class="dialog-icon info"><i class="ti ti-file-search"></i></span><div style="flex:1"><h3>Выбрать документ</h3><p class="text-secondary" style="margin-top:4px"> Добавить тег + Путь будет записан в поле документа. + На сервере уже сохранена более новая версия. Обновите страницу, проверьте изменения и сохраните документ повторно. + щ + н + Название + т + ъ + й + д + Только поля + е + з + ё + Заметка добавлена + п + ю + ч + ы + JSON-снимок пересобран + ш + о + к + ещё не создан + см + Документ восстановлен + API-токен отозван + Черновик + Ревизии удалены + р + ф + ц + м + а + Поле # + Заметка удалена + б + я + Документ сохранён + с + г + Ревизия удалена в + ь + х + щ + у + можно оставить пустым + кг Пресет создания сохранён + Б + Редирект удалён + л Пресет удалён + ж Документ # + Редирект сохранён + API-токен создан + э + и + после сохранения Новый + Шаблон diff --git a/adminx/modules/Documents/language/ru/zz-interface.xml b/adminx/modules/Documents/language/ru/zz-interface.xml new file mode 100644 index 0000000..3c27252 --- /dev/null +++ b/adminx/modules/Documents/language/ru/zz-interface.xml @@ -0,0 +1,9 @@ + + + Разделы не выбраны. + Добавить раздел + Найти раздел + Убрать раздел + документ # + скрыт + diff --git a/adminx/modules/Documents/view/_product_panels.twig b/adminx/modules/Documents/view/_product_panels.twig new file mode 100644 index 0000000..08368c2 --- /dev/null +++ b/adminx/modules/Documents/view/_product_panels.twig @@ -0,0 +1,225 @@ +{% if catalog_mode and not is_new %} +
+
+
+
+
Карточка товара
+

Акции товара

+

Настройте скидку на этот или сопутствующий товар либо выберите подарок.

+
+ +
+ + {% if not product_promotions_available %} +
Таблицы акций ещё не созданы

Примените миграции модуля «Магазин».

+ {% elseif not can_manage_product_promotions %} +
Недостаточно прав

Для настройки нужны права на товары и управление заказами.

+ {% else %} +
+
+

Правила для товара

Простые правила можно изменить здесь. Массовые открываются в общих настройках.

{{ product_promotions|length }}
+
+ {% for item in product_promotions %} +
+ +
+ {{ item.title }} + + {% if item.reward_type == 'gift' %} + Подарок: {{ item.gift_product.title|default('товар не найден') }} + {% elseif item.reward_type == 'percent' %} + Скидка {{ item.reward_value|number_format(0, '.', ' ') }}% + {% elseif item.reward_type == 'fixed_price' %} + Цена {{ item.reward_value|number_format(0, '.', ' ') }} ₽ + {% else %} + Скидка {{ item.reward_value|number_format(0, '.', ' ') }} ₽ + {% endif %} + {% if item.reward_type != 'gift' and item.reward_products %} · {{ item.reward_products|first.title }}{% endif %} + +
+
+ {% if item.direct_product_rule %} + + + + {% else %} + общее правило + + {% endif %} +
+
+ {% else %} +
Акций для товара пока нетСоздайте скидку или выберите товар в подарок.
+ {% endfor %} +
+
+ +
+ + + +

Новая акция

Редактируемый товар уже выбран как условие.

+
+ + + + + + + + + + + +
+
Как сработает

Укажите размер скидки.

+
+
+
+ {% endif %} +
+{% endif %} + +{% if catalog_mode and not is_new %} +
+

Варианты товара

{% if variant_group %}Группа #{{ variant_group.id }}{% endif %}
+
+ {% if variant_group %} +
{{ variant_group.title }}

{{ variant_group.is_primary ? 'Основной вариант группы' : 'Вариант группы' }}

+ Управление вариантами + {% else %} +
Товар пока не объединён с вариантами

Создайте группу на основе текущего товара.

+
+ + + +
+ {% endif %} +
+
+{% endif %} + +{% if catalog_mode and not is_new %} +
+ {% if native_attributes.attributes_count %} +
+ +
+
+
+
Карточка товара
+

Нативные характеристики

+

Характеристики сгруппированы по назначению. Единицы измерения сохраняются вместе со схемой и не вводятся вручную.

+
+
{{ native_attributes.attributes_count }} полей
+
+
+ {% for group in native_attributes.groups %} +
+
{{ group.name }}{{ group.set_name }}
{{ group.items|length }}
+
+ {% for item in group.items %} + {% if item.value_type == 'multi_choice' and item.option_items %} +
+ {{ item.label_override ?: item.name }}{% if item.required %}*{% endif %} +
+ {% for option in item.option_items %} + + {% endfor %} +
+ {{ item.code }}{% if item.value_state %}{{ item.value_state == 'verified' ? 'проверено' : 'черновик' }}{% endif %} +
+ {% else %} + + {% endif %} + {% endfor %} +
+
+ {% endfor %} + +
+
+ {% else %} +
+
+
+
Карточка товара
+

Нативные характеристики

+

Для раздела товара пока не назначены характеристики.

+
+
+
+
+ +
Характеристик пока нет

Назначьте набор характеристик разделу каталога.

+ Настроить наборы +
+
+ {% endif %} +
+{% endif %} + +{% if catalog_mode and not is_new %} +
+
+ + +
+

Доставка и упаковка

Физические грузовые места для расчёта перевозчиками.

+ +
+
+ {% if shipping_profile.legacy %}
Найдены старые данные: {% for alias,value in shipping_profile.legacy %}{{ alias }} {{ value }}{% if not loop.last %}, {% endif %}{% endfor %}. Добавьте грузовое место, чтобы перейти на точный расчёт.
{% endif %} +
+ +
+ {% for package in shipping_profile.packages %} +
+ + + + + + + +
+ {% endfor %} +
+
Грузовые места ещё не добавлены
+ +
+
+ +
+{% endif %} diff --git a/adminx/modules/Documents/view/edit.twig b/adminx/modules/Documents/view/edit.twig index d3ad6a2..e187653 100644 --- a/adminx/modules/Documents/view/edit.twig +++ b/adminx/modules/Documents/view/edit.twig @@ -1,5 +1,5 @@ {% extends '@adminx/main.twig' %} -{% block title %}{{ catalog_mode ? 'Товар #' ~ document.Id : (is_new ? 'Новый документ' : 'Документ #' ~ document.Id) }}{% endblock %} +{% block title %}{{ catalog_mode ? (is_new ? 'Новый товар' : 'Товар #' ~ document.Id) : (is_new ? 'Новый документ' : 'Документ #' ~ document.Id) }}{% endblock %} {% block content %}
-
+ {% if catalog_mode and not is_new and product_readiness.items %} +
+
+ + Готовность товара{{ product_readiness.attention ? product_readiness.attention ~ ' раздела требуют внимания' : 'Обязательные данные заполнены' }} +
+
+ {% for item in product_readiness.items %} + + {% endfor %} +
+
+ {% endif %} + + @@ -101,7 +90,7 @@ {% endif %} -
+
@@ -172,7 +161,7 @@
-
+
@@ -231,7 +220,7 @@
-
+
@@ -294,7 +283,7 @@
-
+
@@ -394,6 +383,42 @@
+ {% if not is_new %} +
+
+
+ +

Связи документов

Прямые и обратные связи из полей документов.

+
+ {{ document_relations.outgoing|length + document_relations.incoming|length }} +
+
+
+
Этот документ ссылается{{ document_relations.outgoing|length }}
+ {% for relation in document_relations.outgoing %} + + {{ relation.target_title ?: 'Документ #' ~ relation.target_document_id }}{{ relation.target_rubric_title }} · {{ relation.field_title ?: relation.field_alias }} + + + {% else %} +

Исходящих связей нет.

+ {% endfor %} +
+
+
На этот документ ссылаются{{ document_relations.incoming|length }}
+ {% for relation in document_relations.incoming %} + + {{ relation.source_title ?: 'Документ #' ~ relation.source_document_id }}{{ relation.source_rubric_title }} · {{ relation.field_title ?: relation.field_alias }} + + + {% else %} +

Обратных связей нет.

+ {% endfor %} +
+
+
+ {% endif %} +
@@ -407,6 +432,9 @@
+{% include '@documents/_product_panels.twig' %} +
+ {% if can_manage %}
{% if active_source == 'legacy' %} -
Что попадает в журнал приложения

Сюда выводятся служебные сообщения публичного runtime: результаты фоновых операций, предупреждения и записи старых интеграций. Действия администраторов находятся во вкладке «Аудит», SQL и 404 вынесены отдельно.

+
Что попадает в журнал приложения

Сюда выводятся служебные сообщения публичного runtime: результаты фоновых операций, предупреждения и записи старых интеграций. Действия администраторов находятся в «Аудите», SQL и 404 вынесены отдельно.

{% endif %}
@@ -78,8 +78,8 @@
-

{{ active_source == 'referrers' ? 'Журнал переходов' : 'Список событий' }}

-

{{ active_source == 'referrers' ? 'Сгруппированные внешние входы, источники и рекламные метки.' : 'Последние записи выбранного источника с фильтром и деталями.' }}

+

{{ active_source == 'audit' ? 'Аудит действий' : (active_source == 'referrers' ? 'Журнал переходов' : 'Список событий') }}

+

{{ active_source == 'audit' ? 'Кто, когда и что изменил в системе.' : (active_source == 'referrers' ? 'Сгруппированные внешние входы, источники и рекламные метки.' : 'Последние записи выбранного источника с фильтром и деталями.') }}

{% else %} -
Права не синхронизированы. Запустите php adminx/tools/setup-rbac.php.
+
Права не синхронизированы. Обновите схему в разделе «База данных → Миграции».
{% endfor %}
diff --git a/adminx/modules/Media/Controller.php b/adminx/modules/Media/Controller.php index 3a8d71c..8cd41f9 100644 --- a/adminx/modules/Media/Controller.php +++ b/adminx/modules/Media/Controller.php @@ -88,7 +88,6 @@ $perPage = max(12, min(60, $perPage)); $listing = Model::listing($dir, $q, $listingType, $page, $perPage); - $folderListing = Model::listing($dir, $q, '', 1, 1); return $this->success('', array( 'data' => array( @@ -96,7 +95,7 @@ 'type' => $type, 'parent_dir' => Model::parentDir($listing['dir']), 'breadcrumbs' => Model::breadcrumbs($listing['dir']), - 'folders' => $this->pickerFolders($folderListing['folders']), + 'folders' => $this->pickerFolders($listing['folders']), 'files' => $this->pickerFiles($listing['files']), 'page' => $listing['page'], 'pages' => $listing['pages'], @@ -238,8 +237,8 @@ return $this->error($e->getMessage(), array(), 422); } - $message = $result['files'] > 0 - ? 'Удалено превью: ' . $result['files'] + $message = $result['roots'] > 0 + ? 'Удалено папок превью: ' . $result['roots'] . ', файлов: ' . $result['files'] : 'В этой папке превью не найдены'; return $this->success($message, array( @@ -352,6 +351,7 @@ $out[] = array( 'name' => isset($folder['name']) ? $folder['name'] : '', 'path' => isset($folder['path']) ? $folder['path'] : '', + 'parent' => isset($folder['parent']) ? $folder['parent'] : '', 'count' => isset($folder['count']) ? (int) $folder['count'] : 0, ); } diff --git a/adminx/modules/Media/Model.php b/adminx/modules/Media/Model.php index 3ce9d4f..c108b20 100644 --- a/adminx/modules/Media/Model.php +++ b/adminx/modules/Media/Model.php @@ -124,28 +124,29 @@ $folders = array(); $files = array(); - $items = @scandir($abs); - if ($items === false) { - $items = array(); - } - - foreach ($items as $name) { - if (!self::isVisibleEntry($name)) { - continue; + if ($q !== '') { + self::searchTree($dir, $abs, $q, $type, $folders, $files); + } else { + $items = @scandir($abs); + if ($items === false) { + $items = array(); } - $path = rtrim($dir, '/') . '/' . $name; - $itemAbs = $abs . DIRECTORY_SEPARATOR . $name; - if (is_dir($itemAbs)) { - if (self::matches($name, $q, $type, true)) { - $folders[] = self::folderRow($path, $itemAbs); + foreach ($items as $name) { + if (!self::isVisibleEntry($name)) { + continue; } - continue; - } + $path = rtrim($dir, '/') . '/' . $name; + $itemAbs = $abs . DIRECTORY_SEPARATOR . $name; + if (is_dir($itemAbs)) { + $folders[] = self::folderRow($path, $itemAbs); + continue; + } - if (is_file($itemAbs) && self::matches($name, $q, $type, false)) { - $files[] = self::fileRow($path, $itemAbs); + if (is_file($itemAbs) && self::matches($name, '', $type, false)) { + $files[] = self::fileRow($path, $itemAbs); + } } } @@ -422,25 +423,60 @@ throw new \RuntimeException('Папка не найдена'); } - $thumbDir = self::thumbnailDirName(); - $target = rtrim($abs, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $thumbDir; - if (!is_dir($target)) { - return array('files' => 0, 'dirs' => 0, 'path' => rtrim($dir, '/') . '/' . $thumbDir); - } - $base = realpath($abs); - $real = realpath($target); - if (!$base || !$real || basename($real) !== $thumbDir || strpos($real, $base . DIRECTORY_SEPARATOR) !== 0) { - throw new \RuntimeException('Некорректная папка превью'); + if (!$base) { + throw new \RuntimeException('Некорректная папка'); } + $thumbDir = self::thumbnailDirName(); + $targets = array(); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + if (!$item->isDir() || $item->isLink() || $item->getFilename() !== $thumbDir) { + continue; + } + + $real = $item->getRealPath(); + if ($real && strpos($real, $base . DIRECTORY_SEPARATOR) === 0) { + $targets[$real] = $real; + } + } + + $direct = rtrim($base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $thumbDir; + if (is_dir($direct)) { + $real = realpath($direct); + if ($real && strpos($real, $base . DIRECTORY_SEPARATOR) === 0) { + $targets[$real] = $real; + } + } + + uksort($targets, function ($left, $right) { + return strlen($right) - strlen($left); + }); $files = 0; $dirs = 0; - if (!self::removeDirWithCount($real, $files, $dirs)) { - throw new \RuntimeException('Не удалось удалить превью'); + $roots = 0; + foreach ($targets as $target) { + if (!is_dir($target)) { + continue; + } + + if (!self::removeDirWithCount($target, $files, $dirs)) { + throw new \RuntimeException('Не удалось удалить превью'); + } + + $roots++; } - return array('files' => $files, 'dirs' => $dirs, 'path' => rtrim($dir, '/') . '/' . $thumbDir); + return array( + 'files' => $files, + 'dirs' => $dirs, + 'roots' => $roots, + 'path' => $dir, + ); } /** @@ -708,7 +744,42 @@ } } - return array('name' => basename($path), 'path' => $path, 'count' => $count); + return array( + 'name' => basename($path), + 'path' => $path, + 'parent' => self::parentDir($path), + 'count' => $count, + ); + } + + protected static function searchTree($dir, $abs, $q, $type, array &$folders, array &$files) + { + $rootLength = strlen(rtrim($abs, DIRECTORY_SEPARATOR)); + $directory = new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS); + $visible = new \RecursiveCallbackFilterIterator($directory, function ($item) { + return !$item->isLink() && self::isVisibleEntry($item->getFilename()); + }); + $iterator = new \RecursiveIteratorIterator( + $visible, + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + $name = $item->getFilename(); + $relative = ltrim(str_replace('\\', '/', substr($item->getPathname(), $rootLength)), '/'); + $path = rtrim($dir, '/') . '/' . $relative; + if ($item->isDir()) { + if (self::matches($name, $q, '', true)) { + $folders[] = self::folderRow($path, $item->getPathname()); + } + + continue; + } + + if ($item->isFile() && self::matches($name, $q, $type, false)) { + $files[] = self::fileRow($path, $item->getPathname()); + } + } } protected static function stats($absRoot) diff --git a/adminx/modules/Media/assets/media.css b/adminx/modules/Media/assets/media.css index 28f2acf..a82f69e 100644 --- a/adminx/modules/Media/assets/media.css +++ b/adminx/modules/Media/assets/media.css @@ -23,7 +23,6 @@ font-size: 20px; line-height: 1.1; font-weight: 800; - font-variant-numeric: tabular-nums; } .media-stat span { font-size: 13px; @@ -271,15 +270,15 @@ button.media-thumb { font: inherit; } .media-tile.is-folder .media-thumb { - background: color-mix(in srgb, #f59e0b 14%, var(--background-subtle)); - color: #d97706; + background: color-mix(in srgb, var(--color-primary) 12%, var(--background-subtle)); + color: var(--color-primary); } .media-tile.is-folder .media-thumb i { font-size: 40px; } .media-tile.is-folder:hover .media-thumb { - background: color-mix(in srgb, #f59e0b 22%, var(--background-subtle)); - color: #b45309; + background: color-mix(in srgb, var(--color-primary) 20%, var(--background-subtle)); + color: var(--blue-700); } .media-name, .media-tile b { @@ -378,6 +377,16 @@ button.media-thumb { text-overflow: ellipsis; white-space: nowrap; } +.media-folder-path { + display: block; + max-width: 280px; + overflow: hidden; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} .media-row-icon, .media-row-preview { width: 38px; @@ -401,7 +410,7 @@ button.media-thumb { outline-offset: -1px; } .media-row-icon.is-folder { - color: #f59e0b; + color: var(--color-primary); } .media-row-actions { justify-content: flex-end; @@ -473,7 +482,6 @@ button.media-thumb { color: #fff; font-size: 11.5px; font-weight: 600; - font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -567,7 +575,6 @@ button.media-thumb { font-size: 12px; font-weight: 600; color: var(--text-secondary); - font-variant-numeric: tabular-nums; } .media-result-stage { display: grid; diff --git a/adminx/modules/Media/assets/media.js b/adminx/modules/Media/assets/media.js index f9856a9..b4b6dd4 100644 --- a/adminx/modules/Media/assets/media.js +++ b/adminx/modules/Media/assets/media.js @@ -165,8 +165,8 @@ var name = button.getAttribute('data-name') || button.getAttribute('data-path') || ''; Adminx.Confirm.open({ kind: 'warning', - title: 'Удалить превью в папке?', - message: 'Будет удалена только служебная папка превью для «' + name + '». Исходные файлы останутся на месте.', + title: 'Удалить все превью?', + message: 'Во всех вложенных папках «' + name + '» будут удалены только служебные каталоги превью. Исходные файлы останутся на месте.', confirmLabel: 'Удалить превью', confirmClass: 'btn-danger', onConfirm: function () { diff --git a/adminx/modules/Media/view/index.twig b/adminx/modules/Media/view/index.twig index df594dc..c3bcf5b 100644 --- a/adminx/modules/Media/view/index.twig +++ b/adminx/modules/Media/view/index.twig @@ -16,7 +16,7 @@ {% if can_manage %}
- +
{% endif %} @@ -33,7 +33,7 @@
{{ stats.images }}Изображений
- +
{{ stats.folders }}Папок
@@ -131,10 +131,10 @@
{{ folder.name }} - {{ folder.count }} объектов + {% if filters.q %}{{ folder.path }} · {% endif %}{{ folder.count }} объектов {% if can_manage %}
- +
@@ -179,8 +179,8 @@ {% for folder in folders %} {{ folder.name }} - Папка{{ folder.count }} объектов- - {% if can_manage %}
{% endif %} + Папка{% if filters.q %}{{ folder.path }}{% else %}{{ folder.count }} объектов{% endif %}- + {% if can_manage %}
{% endif %} {% endfor %} {% for f in files %} diff --git a/adminx/modules/Modules/Controller.php b/adminx/modules/Modules/Controller.php index 48c2f58..f89659e 100644 --- a/adminx/modules/Modules/Controller.php +++ b/adminx/modules/Modules/Controller.php @@ -52,6 +52,7 @@ 'repository' => $repository, 'repository_settings' => ModuleRepository::settings(), 'official_repository' => ModuleRepository::officialSettings(), + 'focus_code' => strtolower(trim(Request::getStr('focus', ''))), )); } diff --git a/adminx/modules/Modules/NativeModuleAdapter.php b/adminx/modules/Modules/NativeModuleAdapter.php index e707be2..7896df6 100644 --- a/adminx/modules/Modules/NativeModuleAdapter.php +++ b/adminx/modules/Modules/NativeModuleAdapter.php @@ -140,10 +140,9 @@ } $migrationPending = !empty($migrationState['pending']); - $needsUpdate = $installed && ( - ($fileVersion !== '' && $fileVersion !== $dbVersion) - || $migrationPending - ); + // The lifecycle version records the package that completed installation. + // A source version bump alone does not require a database operation. + $needsUpdate = $installed && $migrationPending; return array( 'id' => 0, 'code' => $extension['code'], diff --git a/adminx/modules/Modules/assets/modules.css b/adminx/modules/Modules/assets/modules.css index 9b9cd35..c03082b 100644 --- a/adminx/modules/Modules/assets/modules.css +++ b/adminx/modules/Modules/assets/modules.css @@ -65,6 +65,10 @@ .modules-table td { vertical-align: middle; } +.modules-table tr.is-focused > td, +.modules-repository-card tr.is-focused > td { + background: var(--color-primary-soft); +} .modules-identity { display: flex; align-items: flex-start; @@ -146,9 +150,6 @@ color: var(--color-danger); font-size: 12px; } -.modules-version { - font-variant-numeric: tabular-nums; -} .modules-version > * { display: block; } diff --git a/adminx/modules/Modules/assets/modules.js b/adminx/modules/Modules/assets/modules.js index 569ce24..238ba7f 100644 --- a/adminx/modules/Modules/assets/modules.js +++ b/adminx/modules/Modules/assets/modules.js @@ -13,6 +13,7 @@ var state = document.querySelector('[data-module-state]'); var sort = document.querySelector('[data-module-sort]'); var repositorySort = document.querySelector('[data-repository-sort]'); + this.focusRequestedModule(page, search, state); if (search) { search.addEventListener('input', function () { self.filter(); }); } if (state) { state.addEventListener('change', function () { self.filter(); }); } if (sort) { sort.addEventListener('change', function () { self.sortRows('[data-module-row]', sort.value); }); } @@ -50,6 +51,26 @@ this.initArchive(); }, + focusRequestedModule: function (page, search, state) { + var params = new URLSearchParams(window.location.search); + var code = (params.get('focus') || '').trim().toLowerCase(); + if (!code) { return; } + + if (params.get('tab') !== 'catalog' && search) { + search.value = code; + if (state) { state.value = 'all'; } + this.filter(); + } + + window.setTimeout(function () { + var selector = params.get('tab') === 'catalog' ? '[data-repository-row]' : '[data-module-row]'; + var row = page.querySelector(selector + '[data-code="' + CSS.escape(code) + '"]'); + if (!row) { return; } + row.classList.add('is-focused'); + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 80); + }, + base: function () { return Adminx.base(); }, filter: function () { diff --git a/adminx/modules/Modules/language/en/client.xml b/adminx/modules/Modules/language/en/client.xml index 385661a..0e29e83 100644 --- a/adminx/modules/Modules/language/en/client.xml +++ b/adminx/modules/Modules/language/en/client.xml @@ -1,5 +1,6 @@ + Update module The module, its data, settings, menus and rights will be deleted. This action is irreversible. Restore module operation? Successful migrations will be skipped and the aborted step will be re-executed. Please check the given error before proceeding. diff --git a/adminx/modules/Modules/language/ru/client.xml b/adminx/modules/Modules/language/ru/client.xml index b925dc0..aad1bec 100644 --- a/adminx/modules/Modules/language/ru/client.xml +++ b/adminx/modules/Modules/language/ru/client.xml @@ -1,5 +1,6 @@ + Обновить модуль Модуль, его данные, настройки, меню и права будут удалены. Это действие необратимо. Восстановить операцию модуля? Успешные миграции будут пропущены, а оборванный этап выполнится повторно. Перед продолжением проверьте указанную ошибку. diff --git a/adminx/modules/Modules/module.php b/adminx/modules/Modules/module.php index 670f343..c13a460 100644 --- a/adminx/modules/Modules/module.php +++ b/adminx/modules/Modules/module.php @@ -80,13 +80,11 @@ 'code' => 'modules_registry', 'label' => 'Управление', 'url' => '/modules/', + 'exact' => true, 'permission' => 'view_modules', 'group' => 'Система', 'parent' => 'modules', 'sort_order' => 1, - 'match' => array( - '/modules', - ), ), ), 'routes' => array( diff --git a/adminx/modules/Modules/view/index.twig b/adminx/modules/Modules/view/index.twig index 34bcb55..487b12c 100644 --- a/adminx/modules/Modules/view/index.twig +++ b/adminx/modules/Modules/view/index.twig @@ -80,7 +80,7 @@ {% for module in modules %} {% set state = module.error ? 'error' : (module.recoverable ? 'recovery' : (not module.installed ? 'available' : (module.needs_update ? 'update' : (module.enabled ? 'active' : 'inactive')))) %} - +
@@ -97,7 +97,7 @@ {% if module.error %}Ошибка {% elseif module.recoverable %}Восстановление {% elseif not module.installed %}Не установлен - {% elseif module.needs_update %}{{ module.migration_pending ? 'Миграции' : 'Обновление' }} + {% elseif module.needs_update %}Миграции {% elseif module.enabled %}Активен {% else %}Отключён{% endif %} @@ -108,7 +108,7 @@ Файлы: {{ module.file_version ?: '—' }} - {% if module.installed and module.db_version != module.file_version %}БД: {{ module.db_version ?: '—' }}{% endif %} + {% if module.migration_pending %}Установлено: {{ module.db_version ?: '—' }}{% endif %} {% if module.admin_url or can_manage %} @@ -127,7 +127,7 @@ {% if module.removable %}{% endif %} {% elseif module.installed %} - {% if module.needs_update %}{% endif %} + {% if module.needs_update %}{% endif %} {% endif %} @@ -191,7 +191,7 @@ МодульОбластьВерсияСостояние {% for module in repository.items %} - +
{{ module.name }}{{ module.code }}{% if module.author %}{{ module.author }}{% endif %}{% if module.description %}{{ module.description }}{% endif %}
{% if module.kind == 'admin' %}Админка{% else %}Паблик + админка{% endif %} Каталог: {{ module.version }}{% if module.local_version %}Файлы: {{ module.local_version }}{% endif %}{% if module.migration_pending %}БД: {{ module.database_version ?: '—' }}{% endif %} diff --git a/adminx/modules/Navigation/Model.php b/adminx/modules/Navigation/Model.php index 380be11..206e1f3 100644 --- a/adminx/modules/Navigation/Model.php +++ b/adminx/modules/Navigation/Model.php @@ -139,35 +139,7 @@ public static function documentPicker($q = '', $limit = 30) { - $limit = max(5, min(50, (int) $limit)); - $sql = 'SELECT d.Id, d.rubric_id, d.document_title, d.document_alias, d.document_status, r.rubric_title' - . ' FROM ' . ContentTables::table('documents') . ' d' - . ' LEFT JOIN ' . ContentTables::table('rubrics') . ' r ON r.Id = d.rubric_id' - . " WHERE d.document_deleted != '1'"; - $args = array(); - $q = trim((string) $q); - if ($q !== '') { - $sql .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id = %i)'; - $args[] = $q; - $args[] = $q; - $args[] = (int) $q; - } - - $sql .= ' ORDER BY d.document_changed DESC, d.Id DESC LIMIT ' . (int) $limit; - $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); - $out = array(); - foreach ($rows as $row) { - $out[] = array( - 'id' => (int) $row['Id'], - 'rubric_id' => (int) $row['rubric_id'], - 'title' => self::decode(isset($row['document_title']) ? $row['document_title'] : ''), - 'alias' => (string) $row['document_alias'], - 'status' => (int) $row['document_status'], - 'rubric_title' => self::decode(isset($row['rubric_title']) ? $row['rubric_title'] : ''), - ); - } - - return $out; + return (new \App\Content\Documents\DocumentPickerRepository())->search($q, array(), $limit); } public static function stats() @@ -388,6 +360,16 @@ return false; } + $dependencies = \App\Content\ContentTagDependencies::navigation( + $id, + isset($row['alias']) ? (string) $row['alias'] : '' + ); + if ($dependencies) { + throw new \RuntimeException( + 'Навигация используется: ' . implode(', ', $dependencies) . '. Сначала уберите эти связи.' + ); + } + self::clearCache($id, isset($row['alias']) ? $row['alias'] : ''); DB::Delete(self::itemsTable(), 'navigation_id = %i', $id); DB::Delete(self::table(), 'navigation_id = %i', $id); diff --git a/adminx/modules/Navigation/assets/navigation.css b/adminx/modules/Navigation/assets/navigation.css index 6adb58c..fa2db1e 100644 --- a/adminx/modules/Navigation/assets/navigation.css +++ b/adminx/modules/Navigation/assets/navigation.css @@ -19,7 +19,6 @@ font-size: 20px; line-height: 1.1; font-weight: 800; - font-variant-numeric: tabular-nums; } .navigation-stat span { font-size: 13px; @@ -455,7 +454,6 @@ background: var(--background-surface); font-size: 10.5px; font-style: normal; - font-variant-numeric: tabular-nums; } .navigation-template-tab:hover { color: var(--text-primary); @@ -558,7 +556,6 @@ background: var(--blue-100); font-size: 11px; font-weight: 800; - font-variant-numeric: tabular-nums; } .navigation-tags-button { color: var(--cyan-600); @@ -638,7 +635,6 @@ .navigation-tag-nav b { color: var(--text-muted); font-size: 10.5px; - font-variant-numeric: tabular-nums; } .navigation-tag-nav button.is-active b { color: var(--color-primary); @@ -1035,7 +1031,6 @@ .navigation-picker-count { color: var(--text-secondary); font-size: 12px; - font-variant-numeric: tabular-nums; } .navigation-document-main { display: grid; diff --git a/adminx/modules/Navigation/assets/navigation.js b/adminx/modules/Navigation/assets/navigation.js index e0e05b7..9940fa0 100644 --- a/adminx/modules/Navigation/assets/navigation.js +++ b/adminx/modules/Navigation/assets/navigation.js @@ -15,6 +15,7 @@ currentNavigationId: 0, currentNavigationFlat: [], currentItemId: 0, + pendingItemId: 0, dragItem: null, dragGroup: [], dragPlaceholder: null, @@ -97,6 +98,8 @@ }); } + this.openDeepLink(); + document.addEventListener('submit', function (e) { var filter = e.target.closest('.navigation-filter'); if (!filter) { return; } @@ -567,6 +570,18 @@ this.loadItems(); }, + openDeepLink: function () { + var params = new URLSearchParams(window.location.search || ''); + var navigationId = parseInt(params.get('navigation'), 10) || 0; + var itemId = parseInt(params.get('item'), 10) || 0; + if (!navigationId) { return; } + var row = document.querySelector('[data-navigation-row][data-id="' + navigationId + '"]'); + if (!row) { return; } + this.pendingItemId = itemId; + this.openItems(row); + if (Adminx.Drawer) { Adminx.Drawer.open('navigationItemsDrawer'); } + }, + loadItems: function () { var self = this; if (!this.currentNavigationId) { return; } @@ -584,6 +599,11 @@ if (subtitle) { subtitle.textContent = data.navigation ? data.navigation.tag : 'Структура меню'; } if (count) { count.textContent = String(self.currentNavigationFlat.length); } self.renderItems(data.items || []); + if (self.pendingItemId) { + var pendingItemId = self.pendingItemId; + self.pendingItemId = 0; + self.fillItemEdit(pendingItemId); + } if (self.currentItemId) { self.markSelectedItem(self.currentItemId); } diff --git a/adminx/modules/Navigation/module.php b/adminx/modules/Navigation/module.php index 8568f24..9e8f574 100644 --- a/adminx/modules/Navigation/module.php +++ b/adminx/modules/Navigation/module.php @@ -49,7 +49,7 @@ 'icon' => 'ti ti-sitemap', 'permission' => 'view_navigation', 'group' => 'Контент', - 'sort_order' => 25, + 'sort_order' => 26, 'match' => array( '/navigation', ), diff --git a/adminx/modules/PublicSite/Controller.php b/adminx/modules/PublicSite/Controller.php new file mode 100644 index 0000000..8cbfb20 --- /dev/null +++ b/adminx/modules/PublicSite/Controller.php @@ -0,0 +1,164 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\PublicSite; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Content\Introspection\IntrospectionService; + use App\Helpers\Json; + use App\Helpers\Request; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + if (!Permission::check('view_public_site')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + AdminAssets::addStyle($this->base() . '/modules/PublicSite/assets/public-site.css', 50); + $query = Request::getStr('q', ''); + $structure = Model::structure($query); + + return $this->render('@public_site/index.twig', array( + 'stats' => Model::stats(), + 'structure' => $structure, + 'diagnostics' => Model::diagnostics($structure), + 'filters' => array('q' => $query), + )); + } + + public function placements(array $params = array()) + { + if (!Permission::check('view_public_site')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + AdminAssets::addStyle($this->base() . '/modules/PublicSite/assets/public-site.css', 50); + $filters = array( + 'q' => Request::getStr('q', ''), + 'type' => Request::getStr('type', ''), + 'state' => Request::getStr('state', ''), + 'area' => Request::getStr('area', ''), + 'view' => Request::getStr('view', 'placements') === 'usage' ? 'usage' : 'placements', + 'page' => max(1, Request::getInt('page', 1)), + ); + + return $this->render('@public_site/placements.twig', array( + 'placement_map' => Model::placements($filters), + 'filters' => $filters, + 'placement_view' => $filters['view'], + 'rebuilt' => Request::getInt('rebuilt', 0) === 1, + )); + } + + public function siteMap(array $params = array()) + { + if (!Permission::check('view_public_site')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + AdminAssets::addStyle($this->base() . '/modules/PublicSite/assets/public-site.css', 50); + return $this->render('@public_site/map.twig', array( + 'site_map' => Model::siteMap(Request::getStr('q', '')), + 'filters' => array('q' => Request::getStr('q', '')), + )); + } + + public function diagnostics(array $params = array()) + { + if (!Permission::check('view_public_site')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + AdminAssets::addStyle($this->base() . '/modules/PublicSite/assets/public-site.css', 50); + $target = trim(Request::getStr('target', '')); + $requestId = max(0, Request::getInt('request_id', 0)); + $documentId = max(0, Request::getInt('document_id', 0)); + $parametersInput = trim(Request::getStr('parameters', '')); + $page = null; + $request = null; + $pageError = ''; + $requestError = ''; + $service = new IntrospectionService(); + + if ($target !== '') { + try { + $page = $service->page($target); + } catch (\Throwable $e) { + $pageError = $e->getMessage(); + } + } + + if ($requestId > 0 || $documentId > 0 || $parametersInput !== '') { + try { + if ($requestId <= 0 || $documentId <= 0) { + throw new \InvalidArgumentException('Укажите ID запроса и документа'); + } + + $request = $service->request($requestId, $documentId, $this->diagnosticParameters($parametersInput)); + } catch (\Throwable $e) { + $requestError = $e->getMessage(); + } + } + + return $this->render('@public_site/diagnostics.twig', array( + 'target' => $target, + 'page_context' => $page, + 'page_error' => $pageError, + 'page_json' => $page ? Json::encode($page, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) : '', + 'request_id' => $requestId, + 'document_id' => $documentId, + 'parameters_input' => $parametersInput, + 'request_explanation' => $request, + 'request_error' => $requestError, + 'request_json' => $request ? Json::encode($request, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) : '', + )); + } + + public function rebuildPlacements(array $params = array()) + { + if (($error = $this->guardPermission('view_public_site')) !== null) { + return $error; + } + + Model::placements(array(), true); + $this->redirect($this->base() . '/public-site/placements?rebuilt=1'); + } + + protected function diagnosticParameters($input) + { + $input = trim((string) $input); + if ($input === '') { + return array(); + } + + if (substr($input, 0, 1) === '{') { + $decoded = Json::decode($input); + if (!is_array($decoded)) { + throw new \InvalidArgumentException('JSON параметров должен содержать объект'); + } + + return $decoded; + } + + $result = array(); + parse_str($input, $result); + return is_array($result) ? $result : array(); + } + } diff --git a/adminx/modules/PublicSite/Model.php b/adminx/modules/PublicSite/Model.php new file mode 100644 index 0000000..a494aa1 --- /dev/null +++ b/adminx/modules/PublicSite/Model.php @@ -0,0 +1,498 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\PublicSite; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\CatalogTables; + use App\Content\ContentPlacementMap; + use App\Content\ContentTables; + use DB; + + class Model + { + public static function stats() + { + return array( + 'rubrics' => self::count(ContentTables::table('rubrics')), + 'documents' => self::count(ContentTables::table('documents'), "document_deleted!='1'"), + 'requests' => self::count(ContentTables::table('request')), + 'components' => self::count(ContentTables::table('sysblocks')) + + self::count(ContentTables::table('navigation')), + 'catalogs' => self::count(CatalogTables::table('module_catalog_settings')), + 'presentations' => self::count(CatalogTables::table('catalog_card_templates')) + + self::count(CatalogTables::table('catalog_filter_templates')), + ); + } + + public static function structure($query = '') + { + $query = trim((string) $query); + $sql = 'SELECT r.Id id,r.rubric_title title,r.rubric_alias alias,r.rubric_template_id template_id,' + . ' r.rubric_docs_active documents_enabled,CHAR_LENGTH(TRIM(r.rubric_template)) template_length,' + . ' COALESCE(t.template_title,\'\') template_title' + . ' FROM ' . ContentTables::table('rubrics') . ' r' + . ' LEFT JOIN ' . ContentTables::table('templates') . ' t ON t.Id=r.rubric_template_id'; + $args = array(); + if ($query !== '') { + $sql .= ' WHERE r.rubric_title LIKE %s OR r.rubric_alias LIKE %s OR r.Id=%i'; + $args[] = '%' . $query . '%'; + $args[] = '%' . $query . '%'; + $args[] = (int) $query; + } + + $sql .= ' ORDER BY r.rubric_position,r.rubric_title,r.Id'; + $rows = self::queryAll($sql, $args); + $documents = self::documentCounts(); + $fields = self::groupedCount(ContentTables::table('rubric_fields'), 'rubric_id'); + $requests = self::groupedCount(ContentTables::table('request'), 'rubric_id'); + $rubricTemplates = self::groupedCount(ContentTables::table('rubric_templates'), 'rubric_id'); + $catalogs = self::groupedCount(CatalogTables::table('module_catalog_settings'), 'rubric_id'); + + foreach ($rows as &$row) { + $id = (int) $row['id']; + $row['id'] = $id; + $row['title'] = self::decode($row['title']); + $row['alias'] = (string) $row['alias']; + $row['template_id'] = (int) $row['template_id']; + $row['template_title'] = self::decode($row['template_title']); + $row['documents_enabled'] = (int) $row['documents_enabled'] === 1; + $row['documents_count'] = isset($documents[$id]) ? (int) $documents[$id]['total'] : 0; + $row['published_count'] = isset($documents[$id]) ? (int) $documents[$id]['published'] : 0; + $row['fields_count'] = isset($fields[$id]) ? (int) $fields[$id] : 0; + $row['requests_count'] = isset($requests[$id]) ? (int) $requests[$id] : 0; + $row['rubric_templates_count'] = isset($rubricTemplates[$id]) ? (int) $rubricTemplates[$id] : 0; + $row['catalogs_count'] = isset($catalogs[$id]) ? (int) $catalogs[$id] : 0; + $row['issues'] = self::rubricIssues($row); + } + + unset($row); + return $rows; + } + + public static function diagnostics(array $structure) + { + $items = array(); + foreach ($structure as $rubric) { + foreach ($rubric['issues'] as $issue) { + $items[] = array( + 'level' => $issue['level'], + 'title' => $rubric['title'], + 'message' => $issue['message'], + 'url' => '/rubrics?q=' . rawurlencode($rubric['title']), + ); + } + } + + $requestTable = ContentTables::table('request'); + if (self::available($requestTable)) { + $rows = DB::query( + 'SELECT Id,request_title,request_alias,request_template_item FROM ' . $requestTable + . " WHERE TRIM(request_template_item)=''" + . ' ORDER BY request_title,Id LIMIT 50' + )->getAll() ?: array(); + foreach ($rows as $row) { + $items[] = array( + 'level' => 'warning', + 'title' => self::decode($row['request_title']), + 'message' => 'У подборки не заполнен шаблон элемента', + 'url' => '/requests', + ); + } + } + + return array( + 'items' => $items, + 'errors' => count(array_filter($items, function ($item) { return $item['level'] === 'error'; })), + 'warnings' => count(array_filter($items, function ($item) { return $item['level'] === 'warning'; })), + ); + } + + public static function placements(array $filters = array(), $refresh = false) + { + $all = ContentPlacementMap::all((bool) $refresh); + $query = self::lower(isset($filters['q']) ? trim((string) $filters['q']) : ''); + $type = isset($filters['type']) ? trim((string) $filters['type']) : ''; + $state = isset($filters['state']) ? trim((string) $filters['state']) : ''; + $area = isset($filters['area']) ? trim((string) $filters['area']) : ''; + $view = isset($filters['view']) && $filters['view'] === 'usage' ? 'usage' : 'placements'; + $page = isset($filters['page']) ? max(1, (int) $filters['page']) : 1; + $rows = array_values(array_filter($all, function ($item) use ($query, $type, $state, $area) { + if ($type !== '' && $item['component_type'] !== $type) { + return false; + } + + if ($state !== '' && $item['component_status'] !== $state) { + return false; + } + + if ($area !== '' && $item['area'] !== $area) { + return false; + } + + if ($query === '') { + return true; + } + + $haystack = self::lower(implode(' ', array( + $item['source_title'], + $item['source_type_title'], + $item['source_part_title'], + $item['component_title'], + $item['component_alias'], + $item['component_key'], + $item['tag'], + ))); + return strpos($haystack, $query) !== false; + })); + $filteredCount = count($rows); + $usage = self::placementUsage($rows); + $pagination = self::paginate($view === 'usage' ? $usage : $rows, $page, 25); + if ($view === 'usage') { + $usage = $pagination['items']; + } else { + $rows = $pagination['items']; + } + + return array( + 'rows' => $rows, + 'usage' => $usage, + 'stats' => self::placementStats($all, $filteredCount), + 'options' => self::placementOptions($all), + 'pagination' => $pagination, + ); + } + + public static function siteMap($query = '') + { + $query = self::lower(trim((string) $query)); + $navigationTable = ContentTables::table('navigation'); + $itemsTable = ContentTables::table('navigation_items'); + $documentsTable = ContentTables::table('documents'); + $menus = self::available($navigationTable) + ? DB::query('SELECT navigation_id id,title,alias FROM ' . $navigationTable . ' ORDER BY title,navigation_id')->getAll() ?: array() + : array(); + $rows = self::available($itemsTable) + ? DB::query( + 'SELECT i.navigation_item_id id,i.navigation_id,i.parent_id,i.level,i.position,i.status,i.title,i.alias,i.document_id,' + . ' d.document_title,d.document_alias,d.document_status,d.document_deleted' + . ' FROM ' . $itemsTable . ' i LEFT JOIN ' . $documentsTable . ' d ON d.Id=i.document_id' + . ' ORDER BY i.navigation_id,i.parent_id,i.position,i.navigation_item_id' + )->getAll() ?: array() + : array(); + + $itemsByMenu = array(); + $linkedDocuments = array(); + foreach ($rows as $row) { + $row = self::siteMapItem($row); + if ($query !== '' && strpos(self::lower($row['title'] . ' ' . $row['alias'] . ' ' . $row['document_title']), $query) === false) { + $row['query_match'] = false; + } else { + $row['query_match'] = true; + } + + $itemsByMenu[$row['navigation_id']][] = $row; + if ($row['document_id'] > 0) { $linkedDocuments[$row['document_id']] = true; } + } + + $totalItems = 0; + foreach ($menus as &$menu) { + $menu['id'] = (int) $menu['id']; + $menu['title'] = self::decode($menu['title']); + $menu['alias'] = (string) $menu['alias']; + $menuItems = isset($itemsByMenu[$menu['id']]) ? $itemsByMenu[$menu['id']] : array(); + $menu['items_count'] = count($menuItems); + $menu['tree'] = self::siteMapTree($menuItems, 0, $query !== ''); + $totalItems += $menu['items_count']; + } + + unset($menu); + + $orphanSql = 'SELECT d.Id id,d.document_title title,d.document_alias alias,d.document_status status,r.rubric_title rubric_title' + . ' FROM ' . $documentsTable . ' d' + . ' LEFT JOIN ' . ContentTables::table('rubrics') . ' r ON r.Id=d.rubric_id' + . " WHERE d.document_deleted!='1'"; + $args = array(); + if ($linkedDocuments) { + $orphanSql .= ' AND d.Id NOT IN %li'; + $args[] = array_keys($linkedDocuments); + } + + if ($query !== '') { + $orphanSql .= ' AND (LOWER(d.document_title) LIKE %s OR LOWER(d.document_alias) LIKE %s)'; + $args[] = '%' . $query . '%'; + $args[] = '%' . $query . '%'; + } + + $orphanSql .= ' ORDER BY d.document_status DESC,d.document_title,d.Id LIMIT 100'; + $orphans = self::queryAll($orphanSql, $args); + foreach ($orphans as &$orphan) { + $orphan['id'] = (int) $orphan['id']; + $orphan['title'] = self::decode($orphan['title']); + $orphan['rubric_title'] = self::decode($orphan['rubric_title']); + $orphan['active'] = (string) $orphan['status'] === '1'; + } + + unset($orphan); + + return array( + 'menus' => $menus, + 'orphans' => $orphans, + 'stats' => array( + 'menus' => count($menus), + 'items' => $totalItems, + 'linked_documents' => count($linkedDocuments), + 'orphans' => count($orphans), + ), + ); + } + + protected static function siteMapItem(array $row) + { + return array( + 'id' => (int) $row['id'], + 'navigation_id' => (int) $row['navigation_id'], + 'parent_id' => (int) $row['parent_id'], + 'level' => (int) $row['level'], + 'position' => (int) $row['position'], + 'active' => (string) $row['status'] === '1', + 'title' => self::decode($row['title']), + 'alias' => (string) $row['alias'], + 'document_id' => isset($row['document_id']) ? (int) $row['document_id'] : 0, + 'document_title' => self::decode(isset($row['document_title']) ? $row['document_title'] : ''), + 'document_alias' => isset($row['document_alias']) ? (string) $row['document_alias'] : '', + 'document_active' => isset($row['document_status']) && (string) $row['document_status'] === '1' && (string) $row['document_deleted'] !== '1', + ); + } + + protected static function siteMapTree(array $items, $parentId, $filtering, array $visited = array()) + { + $tree = array(); + foreach ($items as $item) { + if ((int) $item['parent_id'] !== (int) $parentId || isset($visited[$item['id']])) { continue; } + $nextVisited = $visited; + $nextVisited[$item['id']] = true; + $item['children'] = self::siteMapTree($items, $item['id'], $filtering, $nextVisited); + if (!$filtering || !empty($item['query_match']) || !empty($item['children'])) { + $tree[] = $item; + } + } + + return $tree; + } + + protected static function rubricIssues(array $row) + { + $issues = array(); + if ((int) $row['template_id'] <= 0 || trim((string) $row['template_title']) === '') { + $issues[] = array('level' => 'error', 'message' => 'Не назначен существующий шаблон сайта'); + } + + if ((int) $row['template_length'] <= 0) { + $issues[] = array('level' => 'warning', 'message' => 'Пустой основной шаблон рубрики'); + } + + if ((int) $row['fields_count'] <= 0) { + $issues[] = array('level' => 'warning', 'message' => 'У типа контента нет полей'); + } + + return $issues; + } + + protected static function placementStats(array $rows, $filtered) + { + $sources = array(); + $total = 0; + $broken = 0; + $modules = 0; + foreach ($rows as $row) { + $sources[$row['source_type'] . ':' . $row['source_id']] = true; + $total += max(1, (int) $row['occurrences']); + $broken += $row['component_status'] === 'broken' ? 1 : 0; + $modules += $row['component_type'] === 'module' ? 1 : 0; + } + + return array( + 'total' => $total, + 'rows' => count($rows), + 'filtered' => (int) $filtered, + 'sources' => count($sources), + 'broken' => $broken, + 'modules' => $modules, + ); + } + + protected static function placementUsage(array $rows) + { + $usage = array(); + foreach ($rows as $row) { + $key = $row['component_type'] . ':' . strtolower((string) $row['component_key']); + if (!isset($usage[$key])) { + $usage[$key] = array( + 'component_type' => $row['component_type'], + 'component_title' => $row['component_title'], + 'component_alias' => $row['component_alias'], + 'component_key' => $row['component_key'], + 'component_status' => $row['component_status'], + 'component_status_title' => $row['component_status_title'], + 'component_url' => $row['component_url'], + 'placements' => 0, + 'sources' => array(), + ); + } + + $usage[$key]['placements'] += max(1, (int) $row['occurrences']); + $usage[$key]['sources'][$row['source_type'] . ':' . $row['source_id']] = true; + if ($row['component_status'] === 'broken') { + $usage[$key]['component_status'] = 'broken'; + $usage[$key]['component_status_title'] = $row['component_status_title']; + } + } + + foreach ($usage as &$item) { + $item['sources'] = count($item['sources']); + } + + unset($item); + $usage = array_values($usage); + usort($usage, function ($left, $right) { + return array( + $left['component_status'] === 'broken' ? 0 : 1, + -$left['placements'], + $left['component_title'], + ) <=> array( + $right['component_status'] === 'broken' ? 0 : 1, + -$right['placements'], + $right['component_title'], + ); + }); + + return $usage; + } + + protected static function paginate(array $items, $page, $limit) + { + $total = count($items); + $limit = max(1, (int) $limit); + $pages = max(1, (int) ceil($total / $limit)); + $page = min(max(1, (int) $page), $pages); + $offset = ($page - 1) * $limit; + + return array( + 'items' => array_slice($items, $offset, $limit), + 'page' => $page, + 'pages' => $pages, + 'total' => $total, + 'from' => $total > 0 ? $offset + 1 : 0, + 'to' => min($total, $offset + $limit), + ); + } + + protected static function placementOptions(array $rows) + { + $options = array( + 'types' => array(), + 'states' => array(), + 'areas' => array(), + ); + $typeTitles = array( + 'block' => 'Блоки', + 'request' => 'Подборки', + 'navigation' => 'Навигации', + 'module' => 'Модульные теги', + ); + foreach ($rows as $row) { + $options['types'][$row['component_type']] = isset($typeTitles[$row['component_type']]) + ? $typeTitles[$row['component_type']] + : $row['component_type']; + $options['states'][$row['component_status']] = $row['component_status_title']; + $options['areas'][$row['area']] = $row['area_title']; + } + + foreach ($options as &$values) { + asort($values, SORT_NATURAL | SORT_FLAG_CASE); + } + + unset($values); + return $options; + } + + protected static function documentCounts() + { + $table = ContentTables::table('documents'); + if (!self::available($table)) { return array(); } + $rows = DB::query( + 'SELECT rubric_id,COUNT(*) total,' + . " SUM(CASE WHEN document_status='1' AND document_deleted!='1' THEN 1 ELSE 0 END) published" + . ' FROM ' . $table . " WHERE document_deleted!='1' GROUP BY rubric_id" + )->getAll() ?: array(); + $counts = array(); + foreach ($rows as $row) { + $counts[(int) $row['rubric_id']] = array( + 'total' => (int) $row['total'], + 'published' => (int) $row['published'], + ); + } + + return $counts; + } + + protected static function groupedCount($table, $column) + { + if (!self::available($table) || !preg_match('/^[a-z0-9_]+$/', (string) $column)) { + return array(); + } + + $rows = DB::query( + 'SELECT `' . $column . '` group_id,COUNT(*) amount FROM ' . $table + . ' GROUP BY `' . $column . '`' + )->getAll() ?: array(); + $counts = array(); + foreach ($rows as $row) { $counts[(int) $row['group_id']] = (int) $row['amount']; } + return $counts; + } + + protected static function count($table, $where = '') + { + if (!self::available($table)) { return 0; } + return (int) DB::query('SELECT COUNT(*) FROM ' . $table . ($where !== '' ? ' WHERE ' . $where : ''))->getValue(); + } + + protected static function queryAll($sql, array $args) + { + return call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll() ?: array(); + } + + protected static function available($table) + { + try { + return DatabaseSchema::tableExists($table); + } catch (\Throwable $e) { + return false; + } + } + + protected static function decode($value) + { + return html_entity_decode((string) $value, ENT_QUOTES, 'UTF-8'); + } + + protected static function lower($value) + { + $value = (string) $value; + return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value); + } + } diff --git a/adminx/modules/PublicSite/PresentationModel.php b/adminx/modules/PublicSite/PresentationModel.php new file mode 100644 index 0000000..f368d12 --- /dev/null +++ b/adminx/modules/PublicSite/PresentationModel.php @@ -0,0 +1,271 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\PublicSite; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Common\ModuleManager; + use App\Content\CatalogTables; + use App\Content\ContentTables; + use App\Content\Documents\DocumentPickerRepository; + use App\Content\Presentation\PresentationAssignmentRepository; + use App\Content\Presentation\PresentationPreview; + use App\Content\Presentation\PresentationRepository; + use App\Content\Presentation\PresentationSyntax; + use DB; + + class PresentationModel + { + protected static $targets; + + public static function documents($limit = 100) + { + return (new DocumentPickerRepository())->search('', array(), max(1, min(100, (int) $limit))); + } + + public static function legacySources() + { + $sources = array(); + self::appendLegacy( + $sources, + CatalogTables::table('catalog_card_templates'), + 'Товарные карточки', + '/catalog/card-templates', + 'ti-layout-cards' + ); + self::appendLegacy( + $sources, + CatalogTables::table('catalog_filter_templates'), + 'Фильтры товарного каталога', + '/catalog/filter-templates', + 'ti-adjustments-horizontal' + ); + return $sources; + } + + public static function assignmentTargets() + { + if (self::$targets !== null) { return self::$targets; } + $targets = array( + 'global' => array(array( + 'key' => '0', 'title' => 'Везде', 'meta' => 'Общее назначение', + 'url' => '', 'available' => true, + )), + 'rubric' => array(), + 'request' => array(), + 'catalog' => array(), + 'module' => array(), + ); + $rubrics = ContentTables::table('rubrics'); + if (DatabaseSchema::tableExists($rubrics)) { + $rows = DB::query('SELECT Id,rubric_title FROM ' . $rubrics . ' ORDER BY rubric_title,Id')->getAll() ?: array(); + foreach ($rows as $row) { + $targets['rubric'][] = array( + 'key' => (string) (int) $row['Id'], + 'title' => self::decode($row['rubric_title']), + 'meta' => 'Рубрика #' . (int) $row['Id'], + 'url' => '/rubrics/' . (int) $row['Id'], + 'available' => true, + ); + } + } + + $requests = ContentTables::table('request'); + if (DatabaseSchema::tableExists($requests)) { + $rows = DB::query('SELECT Id,request_title,request_alias FROM ' . $requests . ' ORDER BY request_title,Id')->getAll() ?: array(); + foreach ($rows as $row) { + $title = self::decode($row['request_title']); + if ($title === '') { $title = 'Запрос #' . (int) $row['Id']; } + $targets['request'][] = array( + 'key' => (string) (int) $row['Id'], + 'title' => $title, + 'meta' => trim((string) $row['request_alias']) !== '' ? (string) $row['request_alias'] : 'Запрос #' . (int) $row['Id'], + 'url' => '/requests/' . (int) $row['Id'], + 'available' => true, + ); + } + } + + $items = CatalogTables::table('module_catalog_items'); + $settings = CatalogTables::table('module_catalog_settings'); + if (DatabaseSchema::tableExists($items) && DatabaseSchema::tableExists($settings)) { + $rows = DB::query( + 'SELECT i.id,i.name,i.level,i.rubric_id,i.field_id,COALESCE(s.purpose,\'content\') purpose' + . ' FROM ' . $items . ' i LEFT JOIN ' . $settings + . ' s ON s.rubric_id=i.rubric_id AND s.field_id=i.field_id' + . ' ORDER BY s.purpose DESC,i.rubric_id,i.field_id,i.level,i.position,i.id' + )->getAll() ?: array(); + foreach ($rows as $row) { + $targets['catalog'][] = array( + 'key' => (string) (int) $row['id'], + 'title' => str_repeat('— ', max(0, min(5, (int) $row['level']))) . self::decode($row['name']), + 'meta' => ((string) $row['purpose'] === 'commerce' ? 'Товарный раздел' : 'Раздел контента') . ' #' . (int) $row['id'], + 'url' => '/catalog/' . (int) $row['rubric_id'] . '/' . (int) $row['field_id'] . '?item=' . (int) $row['id'], + 'available' => true, + ); + } + } + + foreach (ModuleManager::all() as $module) { + if (empty($module['installed']) || empty($module['enabled'])) { continue; } + $extension = isset($module['admin_extension']) && is_array($module['admin_extension']) + ? $module['admin_extension'] + : array(); + $url = isset($extension['url']) ? trim((string) $extension['url']) : ''; + if ($url === '') { $url = '/modules/' . (string) $module['code']; } + $targets['module'][] = array( + 'key' => strtolower((string) $module['code']), + 'title' => (string) $module['name'], + 'meta' => 'Модуль · ' . (string) $module['code'], + 'url' => $url, + 'available' => true, + ); + } + + usort($targets['module'], function ($left, $right) { + return strcasecmp((string) $left['title'], (string) $right['title']); + }); + self::$targets = $targets; + return self::$targets; + } + + public static function assignments($presentationId) + { + $targets = self::assignmentTargetMap(); + $items = PresentationAssignmentRepository::all((int) $presentationId); + foreach ($items as &$item) { + $key = (string) $item['target_type'] . ':' . (string) $item['target_key']; + $target = isset($targets[$key]) ? $targets[$key] : null; + $item['target_title'] = $target ? (string) $target['title'] : 'Цель больше не существует'; + $item['target_meta'] = $target ? (string) $target['meta'] : $key; + $item['target_url'] = $target ? (string) $target['url'] : ''; + $item['target_available'] = (bool) $target; + } + + unset($item); + return $items; + } + + public static function validateAssignment(array $input) + { + $type = strtolower(trim(isset($input['target_type']) ? (string) $input['target_type'] : '')); + $key = strtolower(trim(isset($input['target_key']) ? (string) $input['target_key'] : '')); + if ($type === 'global') { $key = '0'; } + $targets = self::assignmentTargetMap(); + if (!isset($targets[$type . ':' . $key])) { + throw new \InvalidArgumentException('Выберите существующую цель назначения'); + } + + $input['target_type'] = $type; + $input['target_key'] = $key; + return $input; + } + + public static function diagnose($presentationId, array $input) + { + $presentationId = (int) $presentationId; + $item = PresentationRepository::one($presentationId); + if (!$item) { throw new \RuntimeException('Представление не найдено'); } + $checks = array(); + $markup = array( + 'Элемент' => isset($input['draft_item_markup']) ? (string) $input['draft_item_markup'] : (string) $item['draft_item_markup'], + 'Обёртка' => isset($input['draft_wrapper_markup']) ? (string) $input['draft_wrapper_markup'] : (string) $item['draft_wrapper_markup'], + 'Пустой результат' => isset($input['draft_empty_markup']) ? (string) $input['draft_empty_markup'] : (string) $item['draft_empty_markup'], + ); + if (trim($markup['Элемент']) === '') { + $checks[] = self::check('error', 'Шаблон элемента пуст', 'Заполните разметку одного материала.'); + } else { + $syntax = PresentationSyntax::checkMany($markup); + $checks[] = self::check($syntax['ok'] ? 'ok' : 'error', 'Twig-шаблоны', $syntax['message']); + } + + $assignments = self::assignments($presentationId); + if (!$assignments) { + $checks[] = self::check('warning', 'Нет назначений', 'Представление сохранено, но пока нигде не используется.', '/public-site/presentations'); + } else { + $missing = array_filter($assignments, function ($assignment) { return empty($assignment['target_available']); }); + $native = array_filter($assignments, function ($assignment) { return (string) $assignment['mode'] === 'native'; }); + $checks[] = self::check( + $missing ? 'error' : 'ok', + 'Цели назначения', + $missing ? 'Есть связи с удалёнными рубриками, запросами, разделами или модулями.' : 'Все ' . count($assignments) . ' назначений ведут к существующим целям.' + ); + if ($native && empty($item['is_published'])) { + $checks[] = self::check('error', 'Новое представление не опубликовано', 'Native-назначение начнёт работать только после публикации.'); + } elseif ($native) { + $checks[] = self::check('ok', 'Публичный режим', count($native) . ' назначений используют опубликованную версию.'); + } else { + $checks[] = self::check('ok', 'Публичный сайт защищён', 'Назначений native нет: текущая разметка сайта не заменяется.'); + } + } + + $documentId = isset($input['preview_document_id']) ? (int) $input['preview_document_id'] : 0; + if ($documentId > 0) { + try { + PresentationPreview::render($documentId, $input); + $checks[] = self::check('ok', 'Предпросмотр', 'Черновик успешно собран на документе #' . $documentId . '.'); + } catch (\Throwable $e) { + $checks[] = self::check('error', 'Предпросмотр', $e->getMessage()); + } + } else { + $checks[] = self::check('warning', 'Предпросмотр не проверен', 'Выберите реальный материал и повторите диагностику.'); + } + + $summary = array('ok' => 0, 'warning' => 0, 'error' => 0); + foreach ($checks as $check) { $summary[$check['status']]++; } + return array( + 'ready' => $summary['error'] === 0, + 'summary' => $summary, + 'checks' => $checks, + ); + } + + protected static function appendLegacy(array &$sources, $table, $title, $url, $icon) + { + if (!DatabaseSchema::tableExists($table)) { return; } + $sources[] = array( + 'title' => (string) $title, + 'url' => (string) $url, + 'icon' => (string) $icon, + 'count' => (int) DB::query('SELECT COUNT(*) FROM ' . $table)->getValue(), + ); + } + + protected static function assignmentTargetMap() + { + $map = array(); + foreach (self::assignmentTargets() as $type => $items) { + foreach ($items as $item) { $map[$type . ':' . (string) $item['key']] = $item; } + } + + return $map; + } + + protected static function check($status, $title, $message, $url = '') + { + return array( + 'status' => (string) $status, + 'title' => (string) $title, + 'message' => (string) $message, + 'url' => (string) $url, + ); + } + + protected static function decode($value) + { + return html_entity_decode(stripslashes((string) $value), ENT_QUOTES, 'UTF-8'); + } + } diff --git a/adminx/modules/PublicSite/PresentationsController.php b/adminx/modules/PublicSite/PresentationsController.php new file mode 100644 index 0000000..aa3c0da --- /dev/null +++ b/adminx/modules/PublicSite/PresentationsController.php @@ -0,0 +1,279 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\PublicSite; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Adminx\Support\CodeEditor; + use App\Common\AdminAssets; + use App\Common\AuditLog; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Content\Presentation\PresentationAssignmentRepository; + use App\Content\Presentation\PresentationDataRegistry; + use App\Content\Presentation\PresentationPreview; + use App\Content\Presentation\PresentationRepository; + use App\Content\Presentation\PresentationRevisionRepository; + use App\Content\Presentation\PresentationSyntax; + use App\Helpers\Request; + + class PresentationsController extends BaseController + { + public function index(array $params = array()) + { + if (!Permission::check('view_public_site')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + AdminAssets::addStyle($this->base() . '/modules/PublicSite/assets/presentations.css', 51); + AdminAssets::addScript($this->base() . '/modules/PublicSite/assets/presentations.js', 51); + CodeEditor::useCodeMirror('htmlmixed'); + CodeEditor::useCodeMirror('text/css'); + $query = Request::getStr('q', ''); + $kind = Request::getStr('kind', ''); + $state = Request::getStr('state', ''); + return $this->render('@public_site/presentations.twig', array( + 'available' => PresentationRepository::available(), + 'presentations' => PresentationRepository::all($query, $kind, $state), + 'stats' => PresentationRepository::stats(), + 'kinds' => PresentationRepository::kinds(), + 'filters' => array('q' => $query, 'kind' => $kind, 'state' => $state), + 'data_groups' => PresentationDataRegistry::groups(true), + 'documents' => PresentationModel::documents(), + 'legacy_sources' => PresentationModel::legacySources(), + 'assignment_targets' => PresentationModel::assignmentTargets(), + 'can_manage' => Permission::check('manage_public_presentations'), + 'defaults' => $this->defaults(), + )); + } + + public function show(array $params = array()) + { + if (!Permission::check('view_public_site')) { return $this->error('Недостаточно прав', array(), 403); } + $item = PresentationRepository::one(isset($params['id']) ? (int) $params['id'] : 0); + if (!$item) { return $this->error('Представление не найдено', array(), 404); } + $item['assignments'] = PresentationModel::assignments($item['id']); + return $this->success('', array('data' => $item)); + } + + public function store(array $params = array()) + { + return $this->persist(0); + } + + public function update(array $params = array()) + { + return $this->persist(isset($params['id']) ? (int) $params['id'] : 0); + } + + public function lint(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $result = PresentationSyntax::checkMany($this->markup(Request::postAll())); + return $result['ok'] + ? $this->success($result['message'], array('data' => $result)) + : $this->error($result['message'], array(), 422); + } + + public function preview(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { + $result = PresentationPreview::render(Request::postInt('preview_document_id', 0), Request::postAll()); + $css = Request::postStr('draft_css', ''); + $html = '' + . '' + . '' + . ($css !== '' ? '' : '') + . '
' . $result['markup'] . '
'; + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Предпросмотр обновлён', array('data' => array( + 'html' => $html, + 'json' => $result['json'], + 'document' => array( + 'id' => isset($result['data']['item']['id']) ? (int) $result['data']['item']['id'] : 0, + 'title' => isset($result['data']['item']['title']) ? (string) $result['data']['item']['title'] : '', + ), + ))); + } + + public function diagnose(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { + $result = PresentationModel::diagnose( + isset($params['id']) ? (int) $params['id'] : 0, + Request::postAll() + ); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $warnings = isset($result['summary']['warning']) ? (int) $result['summary']['warning'] : 0; + return $this->success( + $result['ready'] ? ($warnings ? 'Ошибок нет, есть предупреждения' : 'Представление готово') : 'Найдены ошибки', + array('data' => $result) + ); + } + + public function publish(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $item = PresentationRepository::publish($id, Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('public_site.presentation_published', $id, array('version' => $item['version'])); + return $this->success('Представление опубликовано', array('data' => $item)); + } + + public function copy(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $id = PresentationRepository::copy(isset($params['id']) ? (int) $params['id'] : 0, Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('public_site.presentation_copied', $id, array()); + return $this->success('Копия создана', array('data' => array('id' => $id))); + } + + public function delete(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $deleted = PresentationRepository::delete($id); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + if (!$deleted) { return $this->error('Представление не найдено', array(), 404); } + $this->audit('public_site.presentation_deleted', $id, array()); + return $this->success('Представление удалено'); + } + + public function revisions(array $params = array()) + { + if (!Permission::check('view_public_site')) { return $this->error('Недостаточно прав', array(), 403); } + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!PresentationRepository::one($id)) { return $this->error('Представление не найдено', array(), 404); } + return $this->success('', array('data' => array('revisions' => PresentationRevisionRepository::all($id)))); + } + + public function revision(array $params = array()) + { + if (!Permission::check('view_public_site')) { return $this->error('Недостаточно прав', array(), 403); } + $item = PresentationRevisionRepository::one(isset($params['revision']) ? (int) $params['revision'] : 0); + return $item ? $this->success('', array('data' => $item)) : $this->error('Ревизия не найдена', array(), 404); + } + + public function restoreRevision(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $id = PresentationRevisionRepository::restore(isset($params['revision']) ? (int) $params['revision'] : 0, Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('public_site.presentation_revision_restored', $id, array()); + return $this->success('Ревизия восстановлена в черновик', array('data' => array('id' => $id))); + } + + public function deleteRevision(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = PresentationRevisionRepository::delete(isset($params['revision']) ? (int) $params['revision'] : 0); + return $id ? $this->success('Ревизия удалена') : $this->error('Ревизия не найдена', array(), 404); + } + + public function deleteRevisions(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!PresentationRepository::one($id)) { return $this->error('Представление не найдено', array(), 404); } + return $this->success('Ревизии удалены', array('data' => array( + 'count' => PresentationRevisionRepository::deleteAll($id), + ))); + } + + public function saveAssignment(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $presentationId = isset($params['id']) ? (int) $params['id'] : 0; + try { + $input = PresentationModel::validateAssignment(Request::postAll()); + $id = PresentationAssignmentRepository::save($presentationId, $input, Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $this->audit('public_site.presentation_assignment_saved', $presentationId, array('assignment_id' => $id)); + return $this->success('Назначение сохранено', array('data' => array( + 'assignments' => PresentationModel::assignments($presentationId), + ))); + } + + public function deleteAssignment(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + PresentationAssignmentRepository::delete(isset($params['assignment']) ? (int) $params['assignment'] : 0); + return $this->success('Назначение удалено'); + } + + protected function persist($id) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $savedId = PresentationRepository::save((int) $id, Request::postAll(), Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit($id > 0 ? 'public_site.presentation_updated' : 'public_site.presentation_created', $savedId, array()); + return $this->success($id > 0 ? 'Черновик сохранён' : 'Представление создано', array( + 'data' => array('id' => $savedId), + )); + } + + protected function guard() + { + return $this->guardPermission('manage_public_presentations'); + } + + protected function markup(array $input) + { + return array( + 'Элемент' => isset($input['draft_item_markup']) ? (string) $input['draft_item_markup'] : '', + 'Обёртка' => isset($input['draft_wrapper_markup']) ? (string) $input['draft_wrapper_markup'] : '', + 'Пустой результат' => isset($input['draft_empty_markup']) ? (string) $input['draft_empty_markup'] : '', + ); + } + + protected function defaults() + { + return array( + 'item' => '
' . "\n" + . '

{{ item.title }}

' . "\n" + . ' {% if item.excerpt %}

{{ item.excerpt }}

{% endif %}' . "\n" + . '
', + 'wrapper' => '
' . "\n" . ' {{ content|raw }}' . "\n" . '
', + 'empty' => '
Материалы не найдены.
', + ); + } + + protected function audit($action, $targetId, array $meta) + { + $user = Auth::user(); + AuditLog::record($action, array( + 'actor_id' => Auth::id(), + 'actor_name' => $user && isset($user['name']) ? (string) $user['name'] : '', + 'target_type' => 'presentation', + 'target_id' => (int) $targetId, + 'meta' => $meta, + )); + } + } diff --git a/adminx/modules/PublicSite/assets/presentations.css b/adminx/modules/PublicSite/assets/presentations.css new file mode 100644 index 0000000..8b30524 --- /dev/null +++ b/adminx/modules/PublicSite/assets/presentations.css @@ -0,0 +1,488 @@ +.public-presentations-schema, +.public-site-tabs { + margin-bottom: 18px; +} +.public-presentations-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 20px; +} +.public-presentation-stat { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + padding: 16px; +} +.public-presentation-stat b { + display: block; + font-size: 22px; + line-height: 1; +} +.public-presentation-stat span:not(.icon-tile) { + display: block; + margin-top: 5px; + color: var(--text-secondary); + font-size: 12px; +} +.public-presentations-panel { + margin-bottom: 20px; +} +.public-presentations-section-head { + margin-bottom: 14px; +} +.public-presentations-card { + overflow: hidden; + padding: 0; +} +.public-presentations-filter { + display: grid; + grid-template-columns: minmax(240px, 1fr) minmax(160px, 210px) minmax(160px, 210px) auto auto; + align-items: end; + gap: 8px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} +.public-presentations-card .table-scroll { + overflow: visible; + max-height: none; +} +.public-presentations-table { + width: 100%; + min-width: 760px; +} +.presentation-col-id { + width: 62px; +} +.presentation-col-kind { + width: 180px; +} +.presentation-col-state { + width: 180px; +} +.presentation-col-date { + width: 145px; +} +.presentation-col-actions { + width: 168px; +} +.public-presentation-name { + display: grid; + gap: 4px; +} +.public-presentation-name small { + color: var(--text-secondary); +} +.public-presentation-version { + display: block; + margin-top: 5px; + color: var(--text-secondary); +} +.public-presentation-actions { + justify-content: flex-end; + flex-wrap: nowrap; +} +.public-presentation-actions .is-edit { + color: var(--blue-600); +} +.public-presentation-actions .is-history { + color: var(--violet-600); +} +.public-presentation-actions .is-copy { + color: var(--cyan-600); +} +.public-presentation-actions .is-danger { + color: var(--red-600); +} +.public-presentation-legacy-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.public-presentation-legacy { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + color: inherit; + text-decoration: none; +} +.public-presentation-legacy > span:nth-child(2) { + display: grid; + flex: 1; + gap: 3px; +} +.public-presentation-legacy small { + color: var(--text-secondary); +} +.public-presentation-legacy > i { + color: var(--text-secondary); +} +.public-presentation-drawer.drawer-lg { + width: min(1180px, 66.666vw); + max-width: calc(100vw - 32px); +} +.public-presentation-form { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + min-height: 0; +} +.public-presentation-form .drawer-body { + overflow-x: hidden; +} +.public-presentation-basics { + margin-bottom: 16px; +} +.public-presentation-editor-tabs { + margin-bottom: 14px; + overflow-x: auto; + overflow-y: hidden; +} +.public-presentation-editor-tabs .tab { + flex: 0 0 auto; + white-space: nowrap; +} +.public-presentation-editor, +.public-presentation-data, +.public-presentation-preview, +.public-presentation-assignments, +.public-presentation-diagnostics, +.public-presentation-revisions { + min-width: 0; +} +.public-presentation-editor-head, +.public-presentation-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} +.public-presentation-editor-head > div:first-child, +.public-presentation-panel-head > div:first-child { + display: grid; + gap: 3px; +} +.public-presentation-editor-head small, +.public-presentation-panel-head small { + color: var(--text-secondary); +} +.public-presentation-data-groups { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.public-presentation-data-groups section { + overflow: hidden; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); +} +.public-presentation-data-groups header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + font-weight: 600; +} +.public-presentation-data-groups section > div { + display: grid; + padding: 6px; +} +.public-presentation-data-groups button { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(110px, auto); + align-items: center; + gap: 12px; + padding: 9px 10px; + border: 0; + border-radius: 5px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} +.public-presentation-data-groups button:hover { + background: var(--surface-muted); +} +.public-presentation-data-groups button span { + color: var(--text-secondary); + text-align: right; +} +.public-presentation-preview > .field { + max-width: 720px; + margin-bottom: 12px; +} +.public-presentation-preview-state { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + color: var(--text-secondary); + font-size: 12px; +} +.public-presentation-preview-grid { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr); + gap: 12px; +} +.public-presentation-preview-grid iframe, +.public-presentation-preview-grid pre { + width: 100%; + min-height: 430px; + margin: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: #fff; +} +.public-presentation-preview-grid pre { + overflow: auto; + padding: 14px; + background: var(--surface-muted); + color: var(--text); + white-space: pre-wrap; +} +.public-presentation-assignment-list { + display: grid; + gap: 8px; + margin-bottom: 14px; +} +.public-presentation-assignment { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto auto; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); +} +.public-presentation-assignment > span:first-child, +.public-presentation-assignment-target { + display: grid; + gap: 3px; + min-width: 0; +} +.public-presentation-assignment-target { + color: inherit; + text-decoration: none; +} +.public-presentation-assignment-target small { + color: var(--text-secondary); +} +.public-presentation-assignment-target[href]:hover b { + color: var(--blue-600); +} +.public-presentation-assignment-target.is-missing { + color: var(--red-600); +} +.public-presentation-assignment-form { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)) auto; + align-items: end; + gap: 10px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-muted); +} +.public-presentation-diagnostic-summary { + margin-bottom: 12px; +} +.public-presentation-diagnostic-list { + display: grid; + gap: 8px; +} +.public-presentation-diagnostic { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); +} +.public-presentation-diagnostic > i { + font-size: 20px; +} +.public-presentation-diagnostic > span { + display: grid; + gap: 3px; +} +.public-presentation-diagnostic small { + color: var(--text-secondary); +} +.public-presentation-diagnostic.is-ok > i { + color: var(--green-600); +} +.public-presentation-diagnostic.is-warning > i { + color: var(--amber-600); +} +.public-presentation-diagnostic.is-error > i { + color: var(--red-600); +} +.public-presentation-revision-list { + display: grid; + gap: 6px; + margin-bottom: 14px; +} +.public-presentation-revision-list > button { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: inherit; + text-align: left; + cursor: pointer; +} +.public-presentation-revision-list > button.is-active, +.public-presentation-revision-list > button:hover { + border-color: var(--blue-400); + background: var(--blue-50); +} +.public-presentation-revision-list span { + display: grid; + gap: 3px; +} +.public-presentation-revision-list small { + color: var(--text-secondary); +} +.public-presentation-revision-detail { + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-muted); +} +.public-presentation-revision-detail pre { + min-height: 160px; + max-height: 340px; + margin: 0; + overflow: auto; + white-space: pre-wrap; +} +.public-presentation-footer { + gap: 10px; +} +.public-presentation-footer .public-presentation-save-state { + margin-right: auto; + color: var(--text-secondary); + font-size: 12px; +} +.public-presentation-footer .public-presentation-save-state.is-dirty { + color: var(--amber-700); +} +.public-presentation-footer .public-presentation-save-state.is-ok { + color: var(--green-700); +} +.public-presentation-footer .public-presentation-save-state.is-error { + color: var(--red-700); +} +@media (max-width: 1100px) { + .public-presentations-filter { + grid-template-columns: minmax(220px, 1fr) repeat(2, minmax(150px, 190px)) auto; + } + .public-presentations-filter .btn-ghost { + grid-column: 1 / -1; + justify-self: start; + } + .public-presentation-drawer.drawer-lg { + width: min(920px, calc(100vw - 24px)); + } + .public-presentation-assignment-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 760px) { + .public-presentations-summary, + .public-presentation-legacy-grid, + .public-presentation-data-groups, + .public-presentation-preview-grid { + grid-template-columns: 1fr; + } + .public-presentations-filter, + .public-presentation-assignment-form { + grid-template-columns: 1fr; + } + table.table.public-presentations-table { + display: block; + min-width: 0; + } + table.table.public-presentations-table colgroup, + table.table.public-presentations-table thead { + display: none; + } + table.table.public-presentations-table tbody, + table.table.public-presentations-table tr, + table.table.public-presentations-table td { + display: block; + width: 100%; + } + table.table.public-presentations-table tr { + box-sizing: border-box; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + } + table.table.public-presentations-table td { + box-sizing: border-box; + display: grid; + grid-template-columns: minmax(92px, 30%) minmax(0, 1fr); + gap: 12px; + padding: 6px 0; + border: 0; + } + table.table.public-presentations-table td::before { + color: var(--text-secondary); + content: attr(data-label); + font-size: 12px; + } + table.table.public-presentations-table .public-presentation-actions { + justify-content: flex-start; + } + table.table.public-presentations-table .public-presentation-empty-row { + padding: 0; + } + table.table.public-presentations-table .public-presentation-empty-row td { + display: block; + padding: 0; + } + table.table.public-presentations-table .public-presentation-empty-row td::before { + display: none; + } + table.table.public-presentations-table .public-presentation-empty-row .empty-state { + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: 28px 16px; + white-space: normal; + } + .public-presentation-drawer.drawer-lg { + width: 100vw; + max-width: 100vw; + } + .public-presentation-assignment { + grid-template-columns: 1fr auto; + } + .public-presentation-preview-grid iframe, + .public-presentation-preview-grid pre { + min-height: 320px; + } + .public-presentation-footer { + flex-wrap: wrap; + } + .public-presentation-footer .public-presentation-save-state { + order: -1; + flex-basis: 100%; + } +} diff --git a/adminx/modules/PublicSite/assets/presentations.js b/adminx/modules/PublicSite/assets/presentations.js new file mode 100644 index 0000000..8878813 --- /dev/null +++ b/adminx/modules/PublicSite/assets/presentations.js @@ -0,0 +1,692 @@ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.PublicPresentations = { + form: null, + drawer: null, + dirty: false, + loading: false, + needsReload: false, + currentRevision: 0, + activeEditor: 'draft_item_markup', + targetCatalog: {}, + + init: function () { + this.form = document.getElementById('publicPresentationForm'); + this.drawer = document.getElementById('publicPresentationDrawer'); + if (!this.form || !this.drawer) { return; } + this.targetCatalog = this.readJson('[data-presentation-targets]'); + this.targetTypeChanged(); + var self = this; + + document.addEventListener('click', function (event) { + var target = event.target; + if (target.closest('[data-presentation-new]')) { self.openNew(); return; } + var edit = target.closest('[data-presentation-edit]'); + if (edit) { self.open(Number(edit.getAttribute('data-presentation-edit'))); return; } + var history = target.closest('[data-presentation-revisions]'); + if (history) { self.open(Number(history.getAttribute('data-presentation-revisions')), 'revisions'); return; } + var copy = target.closest('[data-presentation-copy]'); + if (copy) { self.copy(Number(copy.getAttribute('data-presentation-copy'))); return; } + var remove = target.closest('[data-presentation-delete]'); + if (remove) { self.remove(Number(remove.getAttribute('data-presentation-delete'))); return; } + var tab = target.closest('[data-presentation-tab]'); + if (tab) { self.tab(tab.getAttribute('data-presentation-tab')); return; } + if (target.closest('[data-presentation-save]')) { self.save().catch(function () {}); return; } + if (target.closest('[data-presentation-publish]')) { self.publish(); return; } + if (target.closest('[data-presentation-lint]')) { self.lint(); return; } + if (target.closest('[data-presentation-preview]')) { self.preview(); return; } + if (target.closest('[data-presentation-diagnose]')) { self.diagnose(); return; } + var fullscreen = target.closest('[data-presentation-fullscreen]'); + if (fullscreen) { self.fullscreen(fullscreen.getAttribute('data-presentation-fullscreen')); return; } + var variable = target.closest('[data-presentation-variable]'); + if (variable) { self.insertVariable(variable.getAttribute('data-presentation-variable')); return; } + if (target.closest('[data-presentation-assignment-save]')) { self.saveAssignment(); return; } + var assignmentDelete = target.closest('[data-presentation-assignment-delete]'); + if (assignmentDelete) { self.deleteAssignment(Number(assignmentDelete.getAttribute('data-presentation-assignment-delete'))); return; } + var revision = target.closest('[data-presentation-revision]'); + if (revision) { self.showRevision(Number(revision.getAttribute('data-presentation-revision'))); return; } + if (target.closest('[data-presentation-revision-restore]')) { self.restoreRevision(); return; } + if (target.closest('[data-presentation-revision-delete]')) { self.deleteRevision(); return; } + if (target.closest('[data-presentation-revisions-clear]')) { self.clearRevisions(); } + }); + + document.addEventListener('click', function (event) { + var close = event.target.closest('[data-close-drawer]'); + if (!close || !self.drawer.contains(close)) { return; } + if (self.dirty) { + event.preventDefault(); + event.stopImmediatePropagation(); + self.confirmDiscard(); + return; + } + if (self.needsReload) { window.location.reload(); } + }, true); + + document.addEventListener('keydown', function (event) { + if ((event.ctrlKey || event.metaKey) && String(event.key).toLowerCase() === 's' && self.drawer.classList.contains('show')) { + event.preventDefault(); + self.save().catch(function () {}); + return; + } + if (event.key === 'Escape' && self.dirty && self.drawer.classList.contains('show')) { + event.preventDefault(); + event.stopImmediatePropagation(); + self.confirmDiscard(); + } + }, true); + + this.form.addEventListener('input', function (event) { + if (!self.loading && !event.target.closest('.public-presentation-assignment-form')) { self.setDirty(true); } + }); + this.form.addEventListener('change', function (event) { + if (!self.loading && !event.target.closest('.public-presentation-assignment-form') && event.target.name !== 'preview_document_id') { + self.setDirty(true); + } + if (event.target.name === 'assignment_target_type') { self.targetTypeChanged(); } + }); + this.form.addEventListener('submit', function (event) { + event.preventDefault(); + self.save().catch(function () {}); + }); + setTimeout(function () { self.bindEditors(); }, 120); + }, + + base: function () { return this.form.getAttribute('data-base') || ''; }, + resource: function () { return this.base() + '/public-site/presentations'; }, + revisionResource: function () { return this.base() + '/public-site/presentation-revisions'; }, + assignmentResource: function () { return this.base() + '/public-site/presentation-assignments'; }, + id: function () { return Number(this.field('id').value) || 0; }, + field: function (name) { return this.form.querySelector('[name="' + name + '"]'); }, + editor: function (name) { + var field = this.field(name); + return field && field._adminxCodeMirror ? field._adminxCodeMirror : null; + }, + + request: function (url, options) { + return Adminx.Ajax.request(url, options || {}).then(function (response) { + var payload = response.data || {}; + if (!response.ok || !payload.success) { + var error = new Error(payload.message || ('HTTP ' + response.status)); + error.payload = payload; + throw error; + } + return payload; + }); + }, + + bindEditors: function () { + var self = this; + ['draft_item_markup', 'draft_wrapper_markup', 'draft_empty_markup', 'draft_css'].forEach(function (name) { + var editor = self.editor(name); + if (!editor || editor._publicPresentationBound) { return; } + editor._publicPresentationBound = true; + editor.on('focus', function () { self.activeEditor = name; }); + editor.on('change', function () { + if (!self.loading) { self.setDirty(true); } + }); + }); + }, + + openNew: function () { + this.loading = true; + this.form.reset(); + this.field('id').value = ''; + this.field('title').value = 'Новое представление'; + this.field('code').value = 'presentation_' + String(Date.now()).slice(-8); + this.field('kind').value = 'card'; + this.setCode('draft_item_markup', this.defaultValue('item')); + this.setCode('draft_wrapper_markup', this.defaultValue('wrapper')); + this.setCode('draft_empty_markup', this.defaultValue('empty')); + this.setCode('draft_css', ''); + this.loading = false; + this.needsReload = false; + this.currentRevision = 0; + this.setStatus({is_published: false, version: 0, has_changes: true}); + this.renderAssignments([]); + this.clearRevisionView(); + this.clearPreview(); + this.clearDiagnostics(); + this.setDirty(false); + this.tab('item'); + Adminx.Drawer.open('publicPresentationDrawer'); + this.refreshEditors(); + }, + + open: function (id, panel) { + var self = this; + Adminx.Loader.show(); + this.request(this.resource() + '/' + id).then(function (payload) { + self.fill(payload.data || {}); + self.tab(panel || 'item'); + Adminx.Drawer.open('publicPresentationDrawer'); + self.refreshEditors(); + if (panel === 'revisions') { self.loadRevisions(); } + }).catch(function (error) { + self.fail(error); + }).finally(function () { + Adminx.Loader.hide(); + }); + }, + + fill: function (item) { + this.loading = true; + ['id', 'title', 'code', 'kind', 'description', 'draft_settings_json'].forEach(function (name) { + var field = this.field(name); + if (field) { field.value = item[name] == null ? (name === 'draft_settings_json' ? '{}' : '') : item[name]; } + }, this); + ['draft_item_markup', 'draft_wrapper_markup', 'draft_empty_markup', 'draft_css'].forEach(function (name) { + this.setCode(name, item[name] || ''); + }, this); + this.loading = false; + this.needsReload = false; + this.currentRevision = 0; + this.setStatus(item); + this.renderAssignments(item.assignments || []); + this.clearRevisionView(); + this.clearPreview(); + this.clearDiagnostics(); + this.setDirty(false); + }, + + defaultValue: function (name) { + var field = document.querySelector('[data-presentation-default="' + name + '"]'); + return field ? field.value : ''; + }, + + setCode: function (name, value) { + var field = this.field(name), editor = this.editor(name); + if (field) { field.value = value || ''; } + if (editor) { editor.setValue(value || ''); } + }, + + sync: function () { + if (Adminx.CodeEditor) { Adminx.CodeEditor.syncAll(this.form); } + }, + + refreshEditors: function () { + this.bindEditors(); + setTimeout(function () { + if (Adminx.CodeEditor) { Adminx.CodeEditor.refreshAll(); } + }, 80); + }, + + tab: function (name) { + this.drawer.querySelectorAll('[data-presentation-tab]').forEach(function (button) { + var active = button.getAttribute('data-presentation-tab') === name; + button.classList.toggle('is-active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + this.drawer.querySelectorAll('[data-presentation-panel]').forEach(function (panel) { + panel.hidden = panel.getAttribute('data-presentation-panel') !== name; + }); + if (name === 'item') { this.activeEditor = 'draft_item_markup'; } + if (name === 'wrapper') { this.activeEditor = 'draft_wrapper_markup'; } + if (name === 'empty') { this.activeEditor = 'draft_empty_markup'; } + if (name === 'css') { this.activeEditor = 'draft_css'; } + if (name === 'revisions') { this.loadRevisions(); } + this.refreshEditors(); + }, + + save: function (quiet) { + var self = this; + this.sync(); + var id = this.id(); + this.setSaveState('Сохранение...', ''); + return this.request(this.resource() + (id ? '/' + id : ''), { + method: 'POST', + body: new FormData(this.form) + }).then(function (payload) { + var savedId = Number((payload.data || {}).id) || id; + self.field('id').value = savedId; + self.needsReload = true; + self.setDirty(false); + self.setSaveState('Черновик сохранён', 'ok'); + if (!quiet) { Adminx.Toast.show(payload.message || 'Черновик сохранён', 'success'); } + return savedId; + }).catch(function (error) { + self.setSaveState('Не сохранено', 'error'); + self.fail(error); + throw error; + }); + }, + + publish: function () { + var self = this; + this.save(true).then(function (id) { + var data = new FormData(); + data.set('_csrf', Adminx.csrf()); + return self.request(self.resource() + '/' + id + '/publish', {method: 'POST', body: data}); + }).then(function (payload) { + self.setStatus(payload.data || {}); + self.setSaveState('Опубликовано', 'ok'); + self.needsReload = true; + Adminx.Toast.show(payload.message || 'Представление опубликовано', 'success'); + }).catch(function () {}); + }, + + lint: function () { + var self = this; + this.sync(); + var data = new FormData(this.form); + this.setLint('Проверка...', ''); + this.request(this.resource() + '/lint', {method: 'POST', body: data}).then(function (payload) { + self.setLint(payload.message || 'Twig-синтаксис корректен', 'ok'); + }).catch(function (error) { + self.setLint(error.message, 'error'); + }); + }, + + preview: function () { + var self = this; + this.sync(); + Adminx.Loader.show(); + this.request(this.resource() + '/preview', {method: 'POST', body: new FormData(this.form)}).then(function (payload) { + var result = payload.data || {}; + self.drawer.querySelector('[data-presentation-preview-frame]').srcdoc = result.html || ''; + self.drawer.querySelector('[data-presentation-preview-json]').textContent = result.json || '{}'; + var state = self.drawer.querySelector('[data-presentation-preview-state] span'); + if (state) { + state.textContent = result.document && result.document.title + ? 'Показан материал «' + result.document.title + '» · ID ' + result.document.id + : 'Предпросмотр обновлён.'; + } + }).catch(function (error) { + self.fail(error); + }).finally(function () { + Adminx.Loader.hide(); + }); + }, + + clearPreview: function () { + var frame = this.drawer.querySelector('[data-presentation-preview-frame]'); + if (frame) { frame.removeAttribute('srcdoc'); } + var json = this.drawer.querySelector('[data-presentation-preview-json]'); + if (json) { json.textContent = 'После запуска здесь появятся данные, доступные шаблону.'; } + var state = this.drawer.querySelector('[data-presentation-preview-state] span'); + if (state) { state.textContent = 'Выберите материал и запустите предпросмотр.'; } + }, + + fullscreen: function (name) { + var editor = this.editor(name); + if (editor && Adminx.CodeEditor) { Adminx.CodeEditor.toggleFullscreen(editor); } + }, + + insertVariable: function (value) { + var editorName = this.activeEditor === 'draft_css' ? 'draft_item_markup' : this.activeEditor; + var tab = {draft_item_markup: 'item', draft_wrapper_markup: 'wrapper', draft_empty_markup: 'empty'}[editorName] || 'item'; + this.tab(tab); + var editor = this.editor(editorName); + if (editor) { + editor.replaceSelection(value || ''); + editor.focus(); + editor.save(); + } + }, + + saveAssignment: function () { + var id = this.id(), self = this; + if (!id) { + Adminx.Toast.show('Сначала сохраните представление', 'warning'); + return; + } + var data = new FormData(); + data.set('_csrf', Adminx.csrf()); + data.set('context_code', this.field('assignment_context').value); + data.set('target_type', this.field('assignment_target_type').value); + data.set('target_key', this.field('assignment_target_key').value || '0'); + data.set('mode', this.field('assignment_mode').value); + this.request(this.resource() + '/' + id + '/assignments', {method: 'POST', body: data}).then(function (payload) { + self.renderAssignments((payload.data || {}).assignments || []); + self.needsReload = true; + Adminx.Toast.show(payload.message || 'Назначение сохранено', 'success'); + }).catch(function (error) { + self.fail(error); + }); + }, + + deleteAssignment: function (id) { + var self = this; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Удалить назначение?', + message: 'Само представление и публичный шаблон не изменятся.', + confirmLabel: 'Удалить', + onConfirm: function () { + var data = new FormData(); + data.set('_csrf', Adminx.csrf()); + self.request(self.assignmentResource() + '/' + id + '/delete', {method: 'POST', body: data}).then(function () { + self.reloadAssignments(); + self.needsReload = true; + }).catch(function (error) { + self.fail(error); + }); + } + }); + }, + + reloadAssignments: function () { + var self = this, id = this.id(); + if (!id) { return; } + this.request(this.resource() + '/' + id).then(function (payload) { + self.renderAssignments((payload.data || {}).assignments || []); + }).catch(function (error) { + self.fail(error); + }); + }, + + renderAssignments: function (items) { + var list = this.drawer.querySelector('[data-presentation-assignment-list]'); + var contexts = { + content_list: 'Список материалов', + compact_list: 'Компактный список', + slider: 'Слайдер', + search_results: 'Результаты поиска', + related: 'Похожие материалы', + favorites: 'Избранное', + viewed: 'Просмотренные материалы', + catalog_filters: 'Фильтры каталога', + document_page: 'Страница материала' + }; + if (!items.length) { + list.innerHTML = '
Назначений пока нет.
'; + return; + } + var self = this; + list.innerHTML = items.map(function (item) { + var modeClass = item.mode === 'native' ? 'badge-green' : (item.mode === 'preview' ? 'badge-amber' : 'badge-gray'); + var targetClass = item.target_available === false ? ' is-missing' : ''; + var target = '' + self.escape(item.target_title || item.target_key) + '' + self.escape(item.target_meta || '') + ''; + if (item.target_url) { + target = '' + self.escape(item.target_title || item.target_key) + '' + self.escape(item.target_meta || '') + ''; + } + return '
' + self.escape(contexts[item.context_code] || item.context_code) + '' + self.escape(item.updated_label || '') + '' + target + '' + self.escape(item.mode_label || item.mode) + '
'; + }).join(''); + }, + + targetTypeChanged: function () { + var type = this.field('assignment_target_type').value || 'global'; + var select = this.field('assignment_target_key'); + var current = select.value; + var items = this.targetCatalog[type] || []; + var self = this; + select.innerHTML = items.map(function (item) { + return ''; + }).join(''); + if (!items.length) { + select.innerHTML = ''; + select.disabled = true; + } else { + select.disabled = false; + select.value = items.some(function (item) { return String(item.key) === String(current); }) ? current : items[0].key; + } + var hint = this.drawer.querySelector('[data-presentation-target-hint]'); + if (hint) { + hint.textContent = type === 'global' + ? 'Общее назначение для всех подходящих мест.' + : (items.length ? 'Выберите цель по названию. ID вводить не нужно.' : 'Сначала создайте или включите подходящую цель.'); + } + }, + + diagnose: function () { + var self = this; + this.save(true).then(function (id) { + self.sync(); + self.setDiagnosticState('Проверяем...', 'loading'); + return self.request(self.resource() + '/' + id + '/diagnose', { + method: 'POST', + body: new FormData(self.form) + }); + }).then(function (payload) { + self.renderDiagnostics(payload.data || {}); + self.tab('diagnostics'); + }).catch(function (error) { + self.setDiagnosticState('Проверка не выполнена', 'error'); + self.fail(error); + }); + }, + + renderDiagnostics: function (result) { + var summary = result.summary || {}; + var list = this.drawer.querySelector('[data-presentation-diagnostic-list]'); + var self = this; + this.setDiagnosticState( + result.ready + ? (Number(summary.warning || 0) > 0 + ? 'Ошибок нет · предупреждений: ' + Number(summary.warning || 0) + : 'Готово к публикации') + : 'Ошибок: ' + Number(summary.error || 0) + ' · предупреждений: ' + Number(summary.warning || 0), + result.ready ? (Number(summary.warning || 0) > 0 ? 'warning' : 'ok') : 'error' + ); + list.innerHTML = (result.checks || []).map(function (check) { + var icon = check.status === 'ok' ? 'ti-circle-check' : (check.status === 'error' ? 'ti-alert-circle' : 'ti-alert-triangle'); + var action = check.url ? 'Открыть' : ''; + return '
' + self.escape(check.title) + '' + self.escape(check.message) + '' + action + '
'; + }).join('') || '
Результатов проверки нет.
'; + }, + + clearDiagnostics: function () { + this.setDiagnosticState('Проверка ещё не запускалась', ''); + var list = this.drawer.querySelector('[data-presentation-diagnostic-list]'); + if (list) { list.innerHTML = '
Сохраните представление и запустите диагностику.
'; } + }, + + setDiagnosticState: function (message, state) { + var summary = this.drawer.querySelector('[data-presentation-diagnostic-summary]'); + if (!summary) { return; } + var badge = state === 'ok' + ? 'badge-green' + : (state === 'warning' ? 'badge-amber' : (state === 'error' ? 'badge-red' : (state === 'loading' ? 'badge-blue' : 'badge-gray'))); + summary.innerHTML = '' + this.escape(message || '') + ''; + }, + + copy: function (id) { + var self = this; + Adminx.Confirm.open({ + kind: 'info', + title: 'Создать копию?', + message: 'Будет создан новый неопубликованный черновик без назначений.', + confirmLabel: 'Создать копию', + onConfirm: function () { + var data = new FormData(); + data.set('_csrf', Adminx.csrf()); + self.request(self.resource() + '/' + id + '/copy', {method: 'POST', body: data}).then(function () { + window.location.reload(); + }).catch(function (error) { + self.fail(error); + }); + } + }); + }, + + remove: function (id) { + var self = this; + Adminx.Confirm.open({ + kind: 'error', + title: 'Удалить представление?', + message: 'Черновик, опубликованная версия и ревизии будут удалены. Назначенное представление удалить нельзя.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { + var data = new FormData(); + data.set('_csrf', Adminx.csrf()); + self.request(self.resource() + '/' + id + '/delete', {method: 'POST', body: data}).then(function () { + window.location.reload(); + }).catch(function (error) { + self.fail(error); + }); + } + }); + }, + + loadRevisions: function () { + var id = this.id(), self = this; + var list = this.drawer.querySelector('[data-presentation-revision-list]'); + if (!id) { + list.innerHTML = '
Сначала сохраните представление.
'; + return; + } + list.innerHTML = '
Загрузка...
'; + this.request(this.resource() + '/' + id + '/revisions').then(function (payload) { + var items = (payload.data || {}).revisions || []; + self.drawer.querySelector('[data-presentation-revisions-count]').textContent = items.length ? ('Снимков: ' + items.length) : 'Снимков пока нет.'; + self.drawer.querySelector('[data-presentation-revisions-clear]').disabled = !items.length; + list.innerHTML = items.length ? items.map(function (item) { + return ''; + }).join('') : '
Ревизий пока нет.
'; + }).catch(function (error) { + self.fail(error); + }); + }, + + showRevision: function (id) { + var self = this; + this.request(this.revisionResource() + '/' + id).then(function (payload) { + var item = payload.data || {}, snapshot = item.snapshot || {}; + self.currentRevision = Number(item.id) || 0; + self.drawer.querySelectorAll('[data-presentation-revision]').forEach(function (button) { + button.classList.toggle('is-active', Number(button.getAttribute('data-presentation-revision')) === self.currentRevision); + }); + self.drawer.querySelector('[data-presentation-revision-meta]').innerHTML = '' + self.escape(item.action_label || '') + '' + self.escape(item.created_label || '') + ' · ' + self.escape(item.author_name || 'system') + ''; + self.drawer.querySelector('[data-presentation-revision-code]').textContent = snapshot.draft_item_markup || ''; + self.drawer.querySelector('[data-presentation-revision-restore]').disabled = false; + self.drawer.querySelector('[data-presentation-revision-delete]').disabled = false; + }).catch(function (error) { + self.fail(error); + }); + }, + + restoreRevision: function () { + if (!this.currentRevision) { return; } + var self = this, revision = this.currentRevision; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Восстановить черновик?', + message: 'Опубликованная версия публичного сайта не изменится.', + confirmLabel: 'Восстановить', + onConfirm: function () { + var data = new FormData(); + data.set('_csrf', Adminx.csrf()); + self.request(self.revisionResource() + '/' + revision + '/restore', {method: 'POST', body: data}).then(function () { + self.needsReload = true; + self.open(self.id(), 'revisions'); + Adminx.Toast.show('Ревизия восстановлена в черновик', 'success'); + }).catch(function (error) { + self.fail(error); + }); + } + }); + }, + + deleteRevision: function () { + if (!this.currentRevision) { return; } + var self = this, data = new FormData(); + data.set('_csrf', Adminx.csrf()); + this.request(this.revisionResource() + '/' + this.currentRevision + '/delete', {method: 'POST', body: data}).then(function () { + self.clearRevisionView(); + self.loadRevisions(); + }).catch(function (error) { + self.fail(error); + }); + }, + + clearRevisions: function () { + var self = this, id = this.id(); + if (!id) { return; } + Adminx.Confirm.open({ + kind: 'error', + title: 'Удалить все ревизии?', + message: 'Текущий черновик и опубликованная версия сохранятся.', + confirmLabel: 'Удалить все', + confirmClass: 'btn-danger', + onConfirm: function () { + var data = new FormData(); + data.set('_csrf', Adminx.csrf()); + self.request(self.resource() + '/' + id + '/revisions/delete', {method: 'POST', body: data}).then(function () { + self.clearRevisionView(); + self.loadRevisions(); + }).catch(function (error) { + self.fail(error); + }); + } + }); + }, + + clearRevisionView: function () { + this.currentRevision = 0; + var meta = this.drawer.querySelector('[data-presentation-revision-meta]'); + var code = this.drawer.querySelector('[data-presentation-revision-code]'); + if (meta) { meta.textContent = 'Выберите снимок выше.'; } + if (code) { code.textContent = ''; } + this.drawer.querySelectorAll('[data-presentation-revision-restore],[data-presentation-revision-delete]').forEach(function (button) { + button.disabled = true; + }); + }, + + setStatus: function (item) { + var badge = this.drawer.querySelector('[data-presentation-status]'); + badge.textContent = item.is_published ? ('Опубликовано · v' + (item.version || 1)) : 'Черновик'; + badge.className = 'badge ' + (item.is_published ? (item.has_changes ? 'badge-amber' : 'badge-green') : 'badge-gray'); + this.drawer.querySelector('[data-presentation-title]').textContent = item.title || this.field('title').value || 'Новое представление'; + }, + + setDirty: function (value) { + this.dirty = !!value; + this.drawer.classList.toggle('has-unsaved-changes', this.dirty); + if (this.dirty) { this.setSaveState('Есть несохранённые изменения', 'dirty'); } + }, + + setSaveState: function (message, state) { + var element = this.drawer.querySelector('[data-presentation-save-state]'); + element.textContent = message || ''; + element.className = 'public-presentation-save-state' + (state ? ' is-' + state : ''); + }, + + setLint: function (message, state) { + this.drawer.querySelectorAll('[data-presentation-lint-result]').forEach(function (element) { + element.textContent = message || ''; + element.className = 'field-hint' + (state ? ' is-' + state : ''); + }); + }, + + confirmDiscard: function () { + var self = this; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Закрыть без сохранения?', + message: 'Изменения текущего черновика будут потеряны.', + confirmLabel: 'Закрыть', + onConfirm: function () { + self.setDirty(false); + if (self.needsReload) { window.location.reload(); return; } + Adminx.Drawer.close(self.drawer); + } + }); + }, + + readJson: function (selector) { + var node = document.querySelector(selector); + if (!node) { return {}; } + try { return JSON.parse(node.textContent || '{}'); } + catch (error) { return {}; } + }, + + escape: function (value) { + var node = document.createElement('div'); + node.textContent = value == null ? '' : String(value); + return node.innerHTML; + }, + + fail: function (error) { + Adminx.Toast.show((error && error.message) || 'Не удалось выполнить действие', 'error'); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.PublicPresentations.init(); }); + } else { + Adminx.PublicPresentations.init(); + } +})(window, document); diff --git a/adminx/modules/PublicSite/assets/public-site.css b/adminx/modules/PublicSite/assets/public-site.css new file mode 100644 index 0000000..04217c1 --- /dev/null +++ b/adminx/modules/PublicSite/assets/public-site.css @@ -0,0 +1,834 @@ +.public-site-tabs { + margin-bottom: 18px; +} +.public-site-summary { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 20px; +} +.public-site-stat { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + padding: 16px; +} +.public-site-stat b { + display: block; + font-size: 22px; + line-height: 1; +} +.public-site-stat span:not(.icon-tile) { + display: block; + margin-top: 5px; + color: var(--text-secondary); + font-size: 12px; +} +.public-site-panel-header { + margin-bottom: 16px; +} +.public-site-placement-notice, +.public-site-placement-help { + margin-bottom: 16px; +} +.public-site-placement-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} +.public-site-placement-view-tabs { + margin-bottom: 20px; +} +.public-site-structure { + min-width: 0; +} +.public-site-card { + overflow: hidden; + padding: 0; +} +.public-site-placement-filter-card { + margin-bottom: 20px; +} +.public-site-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--border); +} +.public-site-card-title { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.public-site-card-title h2 { + margin: 0; + font-size: 15px; +} +.public-site-card-title p { + margin: 3px 0 0; + font-size: 12px; +} +.public-site-filter { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 18px; + border-bottom: 1px solid var(--border); +} +.public-site-filter .input-wrap { + flex: 1; +} +.public-site-table { + width: 100%; + min-width: 0; +} +.public-site-main-card .table-scroll { + overflow: visible; + max-height: none; +} +.public-site-col-type { + width: 25%; +} +.public-site-col-template { + width: 20%; +} +.public-site-col-numbers { + width: 22%; +} +.public-site-col-state { + width: 27%; +} +.public-site-col-actions { + width: 76px; +} +.public-site-name, +.public-site-template { + display: grid; + gap: 4px; +} +.public-site-name span, +.public-site-template span { + color: var(--text-secondary); + font-size: 12px; +} +.public-site-counts { + display: flex; + flex-wrap: wrap; + gap: 5px 12px; + color: var(--text-secondary); + font-size: 12px; +} +.public-site-counts b { + color: var(--text); +} +.public-site-state { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.public-site-actions { + justify-content: flex-end; +} +.public-site-support-panel, +.public-site-editors-panel { + margin-top: 24px; +} +.public-site-diagnostics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + padding: 8px; + background: var(--border); +} +.public-site-diagnostic { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) 18px; + align-items: center; + gap: 10px; + padding: 12px; + background: var(--surface); + color: inherit; + text-decoration: none; +} +.public-site-diagnostic > i:first-child { + color: var(--amber-600); + font-size: 18px; +} +.public-site-diagnostic > i:last-child { + color: var(--text-secondary); + font-size: 16px; +} +.public-site-diagnostic span { + display: grid; + flex: 1; + gap: 2px; + min-width: 0; +} +.public-site-diagnostic small { + color: var(--text-secondary); + line-height: 1.35; +} +.public-site-editor-links { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + overflow: hidden; + padding: 0; +} +.public-site-editor-links > a { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) 18px; + align-items: center; + gap: 10px; + min-width: 0; + padding: 16px; + border-right: 1px solid var(--border); + color: inherit; + text-decoration: none; +} +.public-site-editor-links > a:last-child { + border-right: 0; +} +.public-site-editor-links > a:hover { + background: var(--surface-muted); +} +.public-site-editor-links > a > i:first-child { + color: var(--blue-600); + font-size: 20px; +} +.public-site-editor-links > a > i:last-child { + color: var(--text-secondary); + font-size: 16px; +} +.public-site-editor-links > a span { + display: grid; + gap: 3px; + min-width: 0; +} +.public-site-editor-links > a small { + color: var(--text-secondary); + line-height: 1.35; +} +.public-site-diagnostic:hover { + background: var(--surface-muted); +} +.public-site-diagnostic-error > i:first-child { + color: var(--red-600); +} +.public-site-empty { + grid-column: 1 / -1; + min-height: 120px; +} +.public-site-empty i { + color: var(--green-600); + font-size: 28px; +} +.public-site-placement-filter { + display: grid; + grid-template-columns: minmax(260px, 1fr) repeat(3, minmax(150px, 190px)) auto auto; + align-items: center; + gap: 8px; +} +.public-site-placement-search { + min-width: 0; +} +.public-site-placement-scroll { + overflow: visible; + max-height: none; +} +.public-site-placement-col-area { + width: 17%; +} +.public-site-placement-col-source, +.public-site-placement-col-component { + width: 29%; +} +.public-site-placement-col-state { + width: 18%; +} +.public-site-placement-col-action { + width: 88px; +} +.public-site-placement-area, +.public-site-placement-source, +.public-site-placement-component { + display: grid; + align-items: start; + gap: 4px; + min-width: 0; +} +.public-site-placement-area small, +.public-site-placement-source small, +.public-site-placement-component small, +.public-site-placement-area span, +.public-site-placement-source span, +.public-site-placement-component span { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.35; +} +.public-site-placement-area { + justify-items: start; +} +.public-site-placement-component code { + overflow-wrap: anywhere; + color: var(--text-secondary); + font-size: 12px; +} +.public-site-placement-actions { + justify-content: flex-end; +} +.public-site-placement-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 16px 16px; + border-top: 1px solid var(--border); +} +.public-site-placement-footer .pagination { + padding-top: 0; +} +.public-site-usage-table th:nth-child(3), +.public-site-usage-table th:nth-child(4), +.public-site-usage-table td:nth-child(3), +.public-site-usage-table td:nth-child(4) { + width: 110px; +} +.public-site-usage-table th:last-child, +.public-site-usage-table td:last-child { + width: 56px; +} +@media (max-width: 1280px) { + .public-site-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .public-site-diagnostics, + .public-site-editor-links { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .public-site-editor-links > a:nth-child(2) { + border-right: 0; + } + .public-site-editor-links > a:nth-child(-n + 2) { + border-bottom: 1px solid var(--border); + } + .public-site-placement-filter { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .public-site-placement-filter .public-site-placement-search { + grid-column: 1 / -1; + } +} +@media (max-width: 720px) { + .public-site-summary, + .public-site-diagnostics, + .public-site-editor-links { + grid-template-columns: 1fr; + } + .public-site-page-head, + .public-site-filter { + align-items: stretch; + flex-direction: column; + } + .public-site-page-actions { + width: 100%; + } + .public-site-page-actions .btn { + flex: 1; + } + .public-site-editor-links > a, + .public-site-editor-links > a:nth-child(2) { + border-right: 0; + border-bottom: 1px solid var(--border); + } + .public-site-editor-links > a:last-child { + border-bottom: 0; + } + .public-site-placement-summary, + .public-site-placement-filter { + grid-template-columns: 1fr; + } + .public-site-placement-filter .public-site-placement-search { + grid-column: auto; + } + .public-site-placement-footer { + align-items: flex-start; + flex-direction: column; + } + .public-site-main-card .table-scroll { + overflow: visible; + } + .public-site-main-card .public-site-table { + display: block; + min-width: 0; + } + .public-site-main-card .public-site-table colgroup, + .public-site-main-card .public-site-table thead { + display: none; + } + .public-site-main-card .public-site-table tbody, + .public-site-main-card .public-site-table tr, + .public-site-main-card .public-site-table td { + display: block; + width: 100%; + } + .public-site-main-card .public-site-table tr { + box-sizing: border-box; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + } + .public-site-main-card .public-site-table td { + box-sizing: border-box; + display: grid; + grid-template-columns: minmax(92px, 30%) minmax(0, 1fr); + gap: 12px; + padding: 6px 0; + border: 0; + } + .public-site-main-card .public-site-table td::before { + color: var(--text-secondary); + content: attr(data-label); + font-size: 12px; + } + .public-site-main-card .public-site-table .public-site-actions { + justify-content: flex-start; + } + .public-site-main-card .public-site-table .public-site-state { + min-width: 0; + } + .public-site-main-card .public-site-table .public-site-state .badge { + max-width: 100%; + white-space: normal; + } + .public-site-placement-card .table-scroll, + .public-site-usage-card .table-scroll { + overflow: visible; + } + .public-site-placement-card table.public-site-table, + .public-site-usage-card table.public-site-table { + display: block; + width: 100%; + min-width: 0; + } + .public-site-placement-card table.public-site-table colgroup, + .public-site-usage-card table.public-site-table colgroup, + .public-site-placement-card table.public-site-table thead, + .public-site-usage-card table.public-site-table thead { + display: none; + } + .public-site-placement-card table.public-site-table tbody, + .public-site-usage-card table.public-site-table tbody, + .public-site-placement-card table.public-site-table tr, + .public-site-usage-card table.public-site-table tr, + .public-site-placement-card table.public-site-table td, + .public-site-usage-card table.public-site-table td { + display: block; + width: 100%; + } + .public-site-placement-card table.public-site-table tr, + .public-site-usage-card table.public-site-table tr { + box-sizing: border-box; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + } + .public-site-placement-card table.public-site-table td, + .public-site-usage-card table.public-site-table td { + box-sizing: border-box; + display: grid; + grid-template-columns: minmax(92px, 30%) minmax(0, 1fr); + gap: 12px; + padding: 6px 0; + border: 0; + } + .public-site-placement-card table.public-site-table td::before, + .public-site-usage-card table.public-site-table td::before { + color: var(--text-secondary); + content: attr(data-label); + font-size: 12px; + } + .public-site-placement-card .public-site-placement-actions, + .public-site-usage-card .public-site-placement-actions { + justify-content: flex-start; + } +} +.public-site-map-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: var(--space-4); +} +.public-site-map-card { + overflow: hidden; +} +.public-site-map-filter .input-wrap { + flex: 1 1 320px; + max-width: 620px; +} +.public-site-map-menus { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); + padding: var(--space-4); +} +.public-site-map-menu { + min-width: 0; +} +.public-site-map-menu > header { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-3); +} +.public-site-map-menu h3, +.public-site-map-menu p { + margin: 0; +} +.public-site-map-menu p { + color: var(--text-secondary); + font-size: 12px; +} +.public-site-map-tree { + display: grid; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} +.public-site-map-tree .public-site-map-tree { + margin-left: 21px; + padding-left: var(--space-3); + border-left: 1px solid var(--border-default); +} +.public-site-map-node { + display: grid; + grid-template-columns: 32px minmax(0, 1fr); + align-items: center; + gap: var(--space-2); + min-height: 44px; + padding: 5px 4px; + border-radius: var(--radius-sm); +} +.public-site-map-node.has-action { + grid-template-columns: 32px minmax(0, 1fr) 40px; +} +.public-site-map-node:hover { + background: var(--surface-muted); +} +.public-site-map-node .icon-tile { + width: 32px; + height: 32px; +} +.public-site-map-copy { + display: grid; + gap: 2px; + min-width: 0; +} +.public-site-map-copy b, +.public-site-map-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.public-site-map-copy small { + color: var(--text-secondary); +} +.public-site-map-tree li.is-muted > .public-site-map-node { + opacity: 0.55; +} +.public-site-orphans-panel { + margin-top: var(--space-6); +} +.public-site-orphans { + display: grid; +} +.public-site-orphans > a { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-height: 52px; + padding: var(--space-2) var(--space-4); + color: var(--text-primary); + text-decoration: none; +} +.public-site-orphans > a + a { + border-top: 1px solid var(--border-default); +} +.public-site-orphans > a:hover { + background: var(--surface-muted); +} +.public-site-orphans > a > span:first-child { + display: grid; + gap: 2px; + min-width: 0; +} +.public-site-orphans small { + color: var(--text-secondary); +} +.public-site-inspector-form form { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-4); +} +.public-site-inspector-form form .input-wrap { + flex: 1; +} +.public-site-inspector-message { + margin-bottom: var(--space-5); +} +.public-site-inspector-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: var(--space-4); +} +.public-site-inspector-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-5); +} +.public-site-findings, +.public-site-inspector-list, +.public-site-base-checks { + display: grid; +} +.public-site-finding { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) 36px; + align-items: center; + gap: var(--space-3); + min-height: 68px; + padding: var(--space-3) var(--space-4); +} +.public-site-finding + .public-site-finding { + border-top: 1px solid var(--border-default); +} +.public-site-finding > i { + font-size: 21px; +} +.public-site-finding.is-error > i { + color: var(--red-600); +} +.public-site-finding.is-warning > i { + color: var(--amber-600); +} +.public-site-finding.is-info > i { + color: var(--blue-600); +} +.public-site-finding.is-ok > i { + color: var(--green-600); +} +.public-site-finding p { + margin: 3px 0; + color: var(--text-secondary); +} +.public-site-finding small { + color: var(--text-secondary); +} +.public-site-inspector-list > a, +.public-site-inspector-list > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-height: 58px; + padding: var(--space-3) var(--space-4); + color: inherit; + text-decoration: none; +} +.public-site-inspector-list > a + a, +.public-site-inspector-list > div + a, +.public-site-inspector-list > a + div, +.public-site-inspector-list > div + div { + border-top: 1px solid var(--border-default); +} +.public-site-inspector-list > a > span:first-child, +.public-site-inspector-list > div > span:first-child { + display: grid; + gap: 3px; + min-width: 0; +} +.public-site-inspector-list > a small, +.public-site-inspector-list > div small { + overflow: hidden; + color: var(--text-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} +.public-site-inspector-list > a:hover { + background: var(--surface-muted); +} +.public-site-json { + margin-top: var(--space-5); + overflow: hidden; +} +.public-site-json summary { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + cursor: pointer; + font-weight: 600; +} +.public-site-json pre { + max-height: 520px; + margin: 0; + overflow: auto; + padding: var(--space-4); + border-top: 1px solid var(--border-default); + background: var(--surface-muted); + color: var(--text-primary); + font-size: 12px; + white-space: pre-wrap; +} +.public-site-field-value { + max-width: 520px; + overflow-wrap: anywhere; +} +.public-site-field-value code { + white-space: pre-wrap; +} +.public-site-request-inspector { + margin-top: var(--space-6); +} +.public-site-request-form form { + display: grid; + grid-template-columns: 150px 150px minmax(260px, 1fr) auto; + align-items: end; + gap: var(--space-3); + padding: var(--space-4); +} +.public-site-request-form form label { + display: grid; + gap: var(--space-2); +} +.public-site-request-form form label > span { + font-size: 12px; + font-weight: 600; +} +.public-site-base-checks { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + background: var(--border-default); +} +.public-site-base-checks > div { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + background: var(--surface); +} +.public-site-base-checks > div > i { + font-size: 18px; +} +.public-site-base-checks > div > span { + display: grid; + gap: 3px; +} +.public-site-base-checks > div small { + color: var(--text-secondary); +} +.public-site-base-checks > div.is-passed > i { + color: var(--green-600); +} +.public-site-base-checks > div.is-failed > i { + color: var(--red-600); +} +.public-site-condition-tree { + display: grid; + gap: var(--space-3); + padding: var(--space-4); +} +.public-site-condition-group { + display: grid; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); +} +.public-site-condition-group .public-site-condition-group { + margin-left: var(--space-4); + background: var(--surface-muted); +} +.public-site-condition-head { + display: flex; + align-items: center; + gap: var(--space-2); +} +.public-site-condition-head > span:last-child { + margin-left: auto; + color: var(--text-secondary); + font-size: 12px; +} +.public-site-condition { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + gap: var(--space-2); + padding: var(--space-2) 0; +} +.public-site-condition > i { + font-size: 18px; +} +.public-site-condition > div { + display: grid; + gap: 3px; +} +.public-site-condition span, +.public-site-condition small { + color: var(--text-secondary); +} +.public-site-condition.is-passed > i { + color: var(--green-600); +} +.public-site-condition.is-failed > i { + color: var(--red-600); +} +@media (max-width: 920px) { + .public-site-map-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .public-site-map-menus { + grid-template-columns: 1fr; + } + .public-site-inspector-grid { + grid-template-columns: 1fr; + } + .public-site-request-form form { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .public-site-request-parameters { + grid-column: 1 / -1; + } +} +@media (max-width: 720px) { + .public-site-map-summary, + .public-site-map-menus, + .public-site-inspector-summary, + .public-site-base-checks { + grid-template-columns: 1fr; + } + .public-site-map-menus { + padding: var(--space-3); + } + .public-site-map-tree .public-site-map-tree { + margin-left: 16px; + padding-left: var(--space-2); + } + .public-site-orphans > a { + align-items: flex-start; + flex-direction: column; + } + .public-site-inspector-form form { + align-items: stretch; + flex-direction: column; + } + .public-site-request-form form { + grid-template-columns: 1fr; + } + .public-site-request-parameters { + grid-column: auto; + } + .public-site-condition-group .public-site-condition-group { + margin-left: 0; + } +} diff --git a/adminx/modules/PublicSite/language/en/client.xml b/adminx/modules/PublicSite/language/en/client.xml new file mode 100644 index 0000000..e529cb3 --- /dev/null +++ b/adminx/modules/PublicSite/language/en/client.xml @@ -0,0 +1,21 @@ + + + Catalog filters + Choose a material + Choose a snapshot above. + Compact list + Compact list + Material list + failed + passed + Inspection results + Material page + Favorites + materials + New presentation + No snapshots yet. + Data available to the template will appear here after preview. + Related materials + Search results + version + diff --git a/adminx/modules/PublicSite/language/en/interface.xml b/adminx/modules/PublicSite/language/en/interface.xml new file mode 100644 index 0000000..5707574 --- /dev/null +++ b/adminx/modules/PublicSite/language/en/interface.xml @@ -0,0 +1,215 @@ + + + Public site + Public site: view structure + View links between content types, documents, requests, catalogs and templates. + A map of content, collections, catalogs and presentation links. + Content types + Materials + Collections + Catalogs + Blocks and menus + Product presentations + First stage + Structure map + Nothing is switched on the public site: this map shows existing entities and links to their standard editors. + Content types + Content type + in the current list + Open content types + Title, alias or ID + Show + Reset + Site template + Composition + State + Actions + alias is not set + not assigned + materials + fields + collections + catalogs + Catalogs + creation disabled + configured + Materials + Content type settings + No content types found. + Diagnostics + Hints only, without automatic changes + No obvious problems were found on the current map. + Collection preview + Ways to verify results without changing the public site + unavailable + Presentation data + Shared palette provided by the core and enabled modules + Classic editors + Familiar sections remain the primary editors + Requests and conditions + Blocks + Reusable fragments + Navigation + Menus and item tree + Sections and filters + The collection item template is empty + An existing site template is not assigned + The main content type template is empty + The content type has no fields + Material + Context + Product + Actions + Material ID + Title + URL + Excerpt + Content type fields + Usage context + List position + Article + Price + Old price + Stock + Images + Badges + Variants + Shipping packages + Add to cart + Favorites + Compare + Current main/item templates and compatible public output. + Data cards + Readable preview of properties from the result contract. + Compact list + Title and primary values on one line. + Table + Materials in rows with selected data in columns. + Structured result for APIs and integrations. + Public site + Shows links between content types, materials, collections, catalogs and presentations in one place. + The map does not switch anything on the public site and links to existing editors. + Content type fields remain universal content storage; attributes supplement them for filters and variants. + Diagnostics only reports clear gaps in required templates. + Classic sections remain a complete way to configure the site. + Public site sections + Presentations + Reusable presentation for materials, collections and catalogs. + New presentation + Storage has not been created yet + Apply the Public site section migration. + Content presentation + Shared presentations + Draft and preview do not change the current public site. + Presentation kind + All kinds + Not published + version + No presentations yet. + Compatibility + Current editors + They keep managing the current output and are not copied automatically. + Ctrl/Cmd+S saves the draft and keeps the editor open. + Stable name used by assignments. + For example: a material card in the news list + Presentation editor + Wrapper + Empty result + Assignments + Item template + One material is available as item. + List wrapper + Rendered elements are available in content and source data in items. + Shown when the collection returns no materials. + Presentation CSS + Styles apply only to this presentation. + Available data + Click a value to insert it into the item template. + Preview with a real material + Only the draft is used; the public site does not change. + Choose a material + Data available to the template will appear here after preview. + Where to use + Current output mode only records the link and switches nothing. + Material list + Compact list + Search results + Related materials + Catalog filters + Material page + Apply to + Target ID or code + Assign + Change history + No snapshots yet. + Choose a snapshot above. + Place + Everywhere + Content type + Collection + Catalog + Module + Public page diagnostics + Shows how the page is assembled and why a document appears in a collection. Nothing is saved or switched. + Page context + How the page is assembled + Enter a public URL or document ID. The inspection reads saved data without executing the page. + Inspect page + Page inspection failed + Inspection results + Open document + Open setting + Render chain + Templates + characters + Document cache + Full page + Document template + not created + Dependencies + Connected components + Blocks, collections, navigation, and modules found in the active template chain. + Connected from + Open component + No components found in templates + Document schema + Fields and actual values + All category fields are shown, including empty and required fields. + Field + Type + Value + required value is missing + empty + filled + The category has no fields + Category collections + no alias + No requests for this category + The document may be used directly or by a module. + Result presentation + No presentations assigned + The page uses standard AVE.cms templates. + Structured page package + Why the document is in the request + Parameters can be entered as + or a JSON object. + Request ID + Document ID + Page parameters + Explain + Request inspection failed + Inspection result + Document is included in the result + Document is excluded from the result + found + excluded + Structured request explanation + passed + failed + skipped + no value + empty + filled + Component + Tag + diff --git a/adminx/modules/PublicSite/language/ru/client.xml b/adminx/modules/PublicSite/language/ru/client.xml new file mode 100644 index 0000000..280d5ac --- /dev/null +++ b/adminx/modules/PublicSite/language/ru/client.xml @@ -0,0 +1,21 @@ + + + Фильтры каталога + Выберите материал + Выберите снимок выше. + Компактный список + Компактный список + Список материалов + не выполнена + выполнена + Результатов проверки + Страница материала + Избранное + материалов + Новое представление + Снимков пока нет. + После запуска здесь появятся данные, доступные шаблону. + Похожие материалы + Результаты поиска + версия + diff --git a/adminx/modules/PublicSite/language/ru/interface.xml b/adminx/modules/PublicSite/language/ru/interface.xml new file mode 100644 index 0000000..dd70446 --- /dev/null +++ b/adminx/modules/PublicSite/language/ru/interface.xml @@ -0,0 +1,215 @@ + + + Публичный сайт + Публичный сайт: просмотр структуры + Просмотр связей рубрик, документов, запросов, каталогов и шаблонов. + Карта связей контента, подборок, каталогов и оформления. + Типов контента + Материалов + Подборок + Каталогов + Блоков и меню + Товарных представлений + Первый этап + Карта структуры + Ничего не переключает в паблике: здесь видны существующие сущности и быстрые переходы в их штатные редакторы. + Типы контента + Тип контента + в текущем списке + Открыть рубрики + Название, alias или ID + Показать + Сбросить + Шаблон сайта + Состав + Состояние + Действия + alias не задан + не назначен + материалов + полей + подборок + каталогов + Каталоги + создание выключено + настроено + Материалы + Настройки типа + Типы контента не найдены. + Диагностика + Только подсказки, без автоматических изменений + Явных проблем в текущей карте нет. + Предпросмотр подборок + Способы проверки результата без изменения публичного сайта + недоступен + Данные представлений + Общая палитра ядра и включённых модулей + Классические редакторы + Привычные разделы остаются основными + Запросы и условия + Блоки + Повторяемые фрагменты + Навигация + Меню и дерево пунктов + Разделы и фильтры + У подборки не заполнен шаблон элемента + Не назначен существующий шаблон сайта + Пустой основной шаблон рубрики + У типа контента нет полей + Материал + Контекст + Товар + Действия + ID материала + Название + Адрес + Анонс + Поля типа контента + Место использования + Позиция в списке + Артикул + Цена + Старая цена + Остаток + Изображения + Метки + Варианты + Грузовые места + Добавить в корзину + Избранное + Сравнение + Текущие шаблоны main/item и совместимый публичный вывод. + Карточки данных + Читаемый предпросмотр свойств из контракта результата. + Компактный список + Название и основные значения в одной строке. + Таблица + Материалы по строкам, выбранные данные по колонкам. + Структурированный результат для API и интеграций. + Публичный сайт + Показывает связи типов контента, материалов, подборок, каталогов и представлений в одном месте. + Карта ничего не переключает в публичной части и ведёт в существующие редакторы. + Поля рубрик остаются универсальным хранилищем контента; атрибуты дополняют их для фильтров и вариантов. + Диагностика сообщает только о явных пробелах в обязательных шаблонах. + Классические разделы остаются полноценным способом настройки сайта. + Разделы публичного сайта + Представления + Повторно используемое оформление материалов, подборок и каталогов. + Новое представление + Хранилище ещё не создано + Примените миграцию раздела «Публичный сайт». + Оформление контента + Общие представления + Черновик и предпросмотр не меняют действующий публичный сайт. + Вид представления + Все виды + Не опубликованные + версия + Представлений пока нет. + Совместимость + Действующие редакторы + Они продолжают управлять текущим выводом и не копируются автоматически. + Ctrl/Cmd+S сохраняет черновик и оставляет редактор открытым. + Стабильное имя для назначений. + Например: карточка материала в списке новостей + Редактор представления + Обёртка + Пустой результат + Назначения + Шаблон элемента + Один материал доступен как item. + Обёртка списка + Готовые элементы доступны в content, исходные данные — в items. + Показывается, когда подборка не вернула материалов. + CSS представления + Стили относятся только к этому представлению. + Доступные данные + Нажмите значение, чтобы вставить его в шаблон элемента. + Предпросмотр на реальном материале + Используется только черновик, публичный сайт не меняется. + Выберите материал + После запуска здесь появятся данные, доступные шаблону. + Где использовать + Режим «Текущий вывод» только фиксирует связь и ничего не переключает. + Список материалов + Компактный список + Результаты поиска + Похожие материалы + Фильтры каталога + Страница материала + Применить к + ID или код цели + Назначить + История изменений + Снимков пока нет. + Выберите снимок выше. + Место + Везде + Типу контента + Подборке + Каталогу + Модулю + Диагностика публичной страницы + Показывает, из чего собрана страница и почему документ попадает в подборку. Ничего не сохраняет и не переключает. + Контекст страницы + Из чего собрана страница + Введите публичный URL или ID документа. Проверка читает сохранённые данные без запуска страницы. + Разобрать страницу + Страница не разобрана + Результатов проверки + Открыть документ + Открыть место настройки + Цепочка вывода + Шаблоны + символов + Кеш документа + Полная страница + Шаблон документа + не создан + Зависимости + Подключённые компоненты + Блоки, подборки, меню и модули, найденные в активной цепочке шаблонов. + Где подключено + Открыть компонент + Компоненты в шаблонах не найдены + Схема документа + Поля и фактические значения + Показываются все поля рубрики, включая пустые и обязательные. + Поле + Тип + Значение + обязательное не заполнено + пусто + заполнено + У рубрики нет полей + Подборки рубрики + без alias + Запросов для этой рубрики нет + Документ может использоваться напрямую или через модуль. + Оформление результатов + Назначенных представлений нет + Страница использует обычные шаблоны AVE.cms. + Структурированный пакет страницы + Почему документ попал в запрос + Параметры можно передать как + или JSON-объект. + ID запроса + ID документа + Параметры страницы + Объяснить + Запрос не проверен + Итог проверки + Документ входит в результат + Документ не входит в результат + найден + исключён + Структурированное объяснение запроса + выполнена + не выполнена + не участвовала + нет значения + пустой + заполнен + Компонент + Тег + diff --git a/adminx/modules/PublicSite/migrations/001_create_presentations.sql b/adminx/modules/PublicSite/migrations/001_create_presentations.sql new file mode 100644 index 0000000..c275e60 --- /dev/null +++ b/adminx/modules/PublicSite/migrations/001_create_presentations.sql @@ -0,0 +1,56 @@ +CREATE TABLE IF NOT EXISTS `{{content_prefix}}_presentations` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(190) NOT NULL, + `code` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `kind` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT 'card', + `description` TEXT NULL, + `draft_item_markup` LONGTEXT NOT NULL, + `draft_wrapper_markup` LONGTEXT NOT NULL, + `draft_empty_markup` LONGTEXT NOT NULL, + `draft_css` LONGTEXT NOT NULL, + `draft_settings_json` TEXT NULL, + `published_item_markup` LONGTEXT NOT NULL, + `published_wrapper_markup` LONGTEXT NOT NULL, + `published_empty_markup` LONGTEXT NOT NULL, + `published_css` LONGTEXT NOT NULL, + `published_settings_json` TEXT NULL, + `is_published` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + `version` INT UNSIGNED NOT NULL DEFAULT 0, + `author_id` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` INT UNSIGNED NOT NULL DEFAULT 0, + `published_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_content_presentation_code` (`code`), + KEY `idx_content_presentation_kind` (`kind`, `is_published`), + KEY `idx_content_presentation_updated` (`updated_at`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `{{content_prefix}}_presentation_revisions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `presentation_id` INT UNSIGNED NOT NULL, + `action` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `snapshot_hash` CHAR(40) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `snapshot_json` LONGTEXT NOT NULL, + `comment` VARCHAR(255) NOT NULL DEFAULT '', + `author_id` INT UNSIGNED NOT NULL DEFAULT 0, + `author_name` VARCHAR(190) NOT NULL DEFAULT '', + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_presentation_revision_owner` (`presentation_id`, `created_at`, `id`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `{{content_prefix}}_presentation_assignments` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `presentation_id` INT UNSIGNED NOT NULL, + `context_code` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `target_type` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `target_key` VARCHAR(96) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '0', + `mode` VARCHAR(16) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT 'legacy', + `settings_json` TEXT NULL, + `author_id` INT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_presentation_assignment_target` (`context_code`, `target_type`, `target_key`), + KEY `idx_presentation_assignment_owner` (`presentation_id`, `mode`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/PublicSite/module.php b/adminx/modules/PublicSite/module.php new file mode 100644 index 0000000..7503a02 --- /dev/null +++ b/adminx/modules/PublicSite/module.php @@ -0,0 +1,82 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'public_site', + 'name' => 'Публичный сайт', + 'version' => '0.4.0', + 'permissions' => array( + 'key' => 'public_site', + 'items' => array( + array( + 'code' => 'view_public_site', + 'group_code' => 'navigation', + 'name' => 'Публичный сайт: просмотр структуры', + 'description' => 'Просмотр связей рубрик, документов, запросов, каталогов и шаблонов.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_public_presentations', + 'group_code' => 'content', + 'name' => 'Публичный сайт: управление представлениями', + 'description' => 'Создание, публикация, назначение и удаление представлений.', + 'sort_order' => 20, + ), + ), + 'icon' => 'ti ti-world', + 'priority' => 18, + ), + 'navigation' => array( + array( + 'code' => 'public_site', + 'label' => 'Публичный сайт', + 'url' => '/public-site', + 'icon' => 'ti ti-world', + 'permission' => 'view_public_site', + 'group' => 'Контент', + 'sort_order' => 24, + 'match' => array('/public-site'), + ), + ), + 'migrations' => array( + array('id' => '001_create_presentations', 'file' => 'migrations/001_create_presentations.sql'), + ), + 'routes' => array( + array('GET', '/public-site', array(\App\Adminx\PublicSite\Controller::class, 'index')), + array('GET', '/public-site/map', array(\App\Adminx\PublicSite\Controller::class, 'siteMap')), + array('GET', '/public-site/placements', array(\App\Adminx\PublicSite\Controller::class, 'placements')), + array('GET', '/public-site/diagnostics', array(\App\Adminx\PublicSite\Controller::class, 'diagnostics')), + array('POST', '/public-site/placements/rebuild', array(\App\Adminx\PublicSite\Controller::class, 'rebuildPlacements')), + array('GET', '/public-site/presentations', array(\App\Adminx\PublicSite\PresentationsController::class, 'index')), + array('POST', '/public-site/presentations/lint', array(\App\Adminx\PublicSite\PresentationsController::class, 'lint')), + array('POST', '/public-site/presentations/preview', array(\App\Adminx\PublicSite\PresentationsController::class, 'preview')), + array('GET', '/public-site/presentations/{id}', array(\App\Adminx\PublicSite\PresentationsController::class, 'show')), + array('POST', '/public-site/presentations', array(\App\Adminx\PublicSite\PresentationsController::class, 'store')), + array('POST', '/public-site/presentations/{id}', array(\App\Adminx\PublicSite\PresentationsController::class, 'update')), + array('POST', '/public-site/presentations/{id}/publish', array(\App\Adminx\PublicSite\PresentationsController::class, 'publish')), + array('POST', '/public-site/presentations/{id}/diagnose', array(\App\Adminx\PublicSite\PresentationsController::class, 'diagnose')), + array('POST', '/public-site/presentations/{id}/copy', array(\App\Adminx\PublicSite\PresentationsController::class, 'copy')), + array('POST', '/public-site/presentations/{id}/delete', array(\App\Adminx\PublicSite\PresentationsController::class, 'delete')), + array('GET', '/public-site/presentations/{id}/revisions', array(\App\Adminx\PublicSite\PresentationsController::class, 'revisions')), + array('POST', '/public-site/presentations/{id}/revisions/delete', array(\App\Adminx\PublicSite\PresentationsController::class, 'deleteRevisions')), + array('GET', '/public-site/presentation-revisions/{revision}', array(\App\Adminx\PublicSite\PresentationsController::class, 'revision')), + array('POST', '/public-site/presentation-revisions/{revision}/restore', array(\App\Adminx\PublicSite\PresentationsController::class, 'restoreRevision')), + array('POST', '/public-site/presentation-revisions/{revision}/delete', array(\App\Adminx\PublicSite\PresentationsController::class, 'deleteRevision')), + array('POST', '/public-site/presentations/{id}/assignments', array(\App\Adminx\PublicSite\PresentationsController::class, 'saveAssignment')), + array('POST', '/public-site/presentation-assignments/{assignment}/delete', array(\App\Adminx\PublicSite\PresentationsController::class, 'deleteAssignment')), + ), + 'view_globals' => array('module_code' => 'public_site'), + ); diff --git a/adminx/modules/PublicSite/view/_placement_pagination.twig b/adminx/modules/PublicSite/view/_placement_pagination.twig new file mode 100644 index 0000000..a370fec --- /dev/null +++ b/adminx/modules/PublicSite/view/_placement_pagination.twig @@ -0,0 +1,22 @@ +{% set pagination = placement_map.pagination %} + diff --git a/adminx/modules/PublicSite/view/_tabs.twig b/adminx/modules/PublicSite/view/_tabs.twig new file mode 100644 index 0000000..f1f2e91 --- /dev/null +++ b/adminx/modules/PublicSite/view/_tabs.twig @@ -0,0 +1,7 @@ + diff --git a/adminx/modules/PublicSite/view/diagnostics.twig b/adminx/modules/PublicSite/view/diagnostics.twig new file mode 100644 index 0000000..27add85 --- /dev/null +++ b/adminx/modules/PublicSite/view/diagnostics.twig @@ -0,0 +1,236 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Диагностика · Публичный сайт{% endblock %} +{% block content %} + +{% macro request_group(group) %} +
+
+ {{ group.operator }} + {{ group.title }} + {{ group.state == 'passed' ? 'выполнена' : (group.state == 'failed' ? 'не выполнена' : 'не участвовала') }} +
+ {% for condition in group.conditions %} +
+ +
+ {{ condition.field }}{% if condition.alias %} · {{ condition.alias }}{% endif %} + {{ condition.actual is same as(null) ? 'нет значения' : condition.actual }} {{ condition.operator }} {{ condition.expected }} + {% if condition.note %}{{ condition.note }}{% endif %} +
+
+ {% endfor %} + {% for child in group.children %}{{ _self.request_group(child) }}{% endfor %} +
+{% endmacro %} + + + + + +{% set public_site_tab = 'diagnostics' %} +{% include '@public_site/_tabs.twig' %} + +
+
+
+
+
Контекст страницы
+

Из чего собрана страница

+

Введите публичный URL или ID документа. Проверка читает сохранённые данные без запуска страницы.

+
+
+
+ +
+ + +
+
+ +{% if page_error %} +
Страница не разобрана

{{ page_error }}

+{% endif %} + +{% if page_context %} +
+
#{{ page_context.document.id }}Документ
+
{{ page_context.fields|length }}Полей
+
{{ page_context.components|length }}Связей
+
{{ page_context.diagnostics|length }}Результатов проверки
+
+ +
+
+
+
Результат

{{ page_context.document.title }}

{{ page_context.document.url }} · {{ page_context.rubric.title }}

+ Открыть документ +
+
+
+ {% for finding in page_context.diagnostics %} +
+ +
{{ finding.title }}

{{ finding.message }}

{{ finding.action }}
+ {% if finding.editor_url %}{% endif %} +
+ {% endfor %} +
+
+
+ +
+
+
Цепочка вывода

Шаблоны

+
+
+ {% for template in [page_context.templates.site, page_context.templates.rubric, page_context.templates.alternate] %} + {% if template %} + {{ template.title }}{{ template.type }} · {{ template.content.length }} символов{{ template.content.empty ? 'пустой' : 'заполнен' }} + {% endif %} + {% endfor %} +
+
+
+
+
Runtime

Кеш документа

+
+
+ {% for type, file in page_context.cache.files %} +
{{ type == 'page' ? 'Полная страница' : 'Шаблон документа' }}{{ file.path }}{{ file.exists ? file.size ~ ' Б' : 'не создан' }}
+ {% endfor %} +
+
+
+
+ +
+
Зависимости

Подключённые компоненты

Блоки, подборки, меню и модули, найденные в активной цепочке шаблонов.

+
+
+ + + + {% for item in page_context.components %} + + + + + + + + {% else %} + + {% endfor %} + +
Где подключеноКомпонентТегСостояние
{{ item.source_title }}{{ item.source_part_title }}{{ item.component_title }}{{ item.tag }}{{ item.component_status_title }}{% if item.component_url %}{% endif %}
Компоненты в шаблонах не найдены
+
+
+
+ +
+
Схема документа

Поля и фактические значения

Показываются все поля рубрики, включая пустые и обязательные.

+
+
+ + + + {% for field in page_context.fields %} + + + + + + + {% else %} + + {% endfor %} + +
ПолеТипЗначениеСостояние
{{ field.title }}{{ field.alias ?: '#' ~ field.id }}{{ field.type }}{% if field.value is iterable %}{{ field.value|json_encode }}{% else %}{{ field.value ?: '—' }}{% endif %}{{ field.required and field.empty ? 'обязательное не заполнено' : (field.empty ? 'пусто' : 'заполнено') }}
У рубрики нет полей
+
+
+
+ +
+
+
Подборки рубрики

Запросы

+
+
+ {% for item in page_context.requests %} + {{ item.title }}#{{ item.id }} · {{ item.alias ?: 'без alias' }} · {{ item.executor }}{{ item.items_per_page }} + {% else %} +
Запросов для этой рубрики нетДокумент может использоваться напрямую или через модуль.
+ {% endfor %} +
+
+
+
+
Оформление результатов

Представления

+
+
+ {% for item in page_context.presentations %} + {{ item.title }}{{ item.context_code }} · {{ item.target_type }}:{{ item.target_key }}{{ item.mode }} + {% else %} +
Назначенных представлений нетСтраница использует обычные шаблоны AVE.cms.
+ {% endfor %} +
+
+
+
+ +
+ Структурированный пакет страницы +
{{ page_json }}
+
+{% endif %} + +
+
+
+
Подборки

Почему документ попал в запрос

Параметры можно передать как catalog=beds&color=white или JSON-объект.

+
+
+
+ {% if target %}{% endif %} + + + + +
+
+
+ +{% if request_error %} +
Запрос не проверен

{{ request_error }}

+{% endif %} + +{% if request_explanation %} +
+
+
+
Итог проверки

{{ request_explanation.matched ? 'Документ входит в результат' : 'Документ не входит в результат' }}

{{ request_explanation.request.title }} · {{ request_explanation.document.title }}

+ {{ request_explanation.matched ? 'найден' : 'исключён' }} +
+
+
+ {% for check in request_explanation.base %} +
{{ check.label }}{{ check.detail }}
+ {% endfor %} +
+
+ {% for group in request_explanation.groups %}{{ _self.request_group(group) }}{% endfor %} +
+
+
+
Структурированное объяснение запроса
{{ request_json }}
+{% endif %} + +{% endblock %} diff --git a/adminx/modules/PublicSite/view/index.twig b/adminx/modules/PublicSite/view/index.twig new file mode 100644 index 0000000..c89c58a --- /dev/null +++ b/adminx/modules/PublicSite/view/index.twig @@ -0,0 +1,204 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Публичный сайт{% endblock %} +{% block content %} + + + + + +{% set public_site_tab = 'structure' %} +{% include '@public_site/_tabs.twig' %} + +
+
+ +
{{ stats.rubrics }}Типов контента
+
+
+ +
{{ stats.documents }}Материалов
+
+
+ +
{{ stats.requests }}Подборок
+
+
+ +
{{ stats.catalogs }}Каталогов
+
+
+ +
{{ stats.components }}Блоков и меню
+
+
+ +
{{ stats.presentations }}Представлений
+
+
+ +
+
+
+
+
Первый этап
+

Карта структуры

+

Ничего не переключает в паблике: здесь видны существующие сущности и быстрые переходы в их штатные редакторы.

+
+
+ +
+
+
+
+ +
+

Типы контента

+

{{ structure|length }} в текущем списке

+
+
+ +
+ +
+
+ + +
+ + {% if filters.q %}Сбросить{% endif %} +
+ +
+ + + + + + + + + + + + + + + + + + + {% for item in structure %} + + + + + + + + {% else %} + + {% endfor %} + +
Тип контентаШаблон сайтаСоставСостояние
+
+ {{ item.title }} + {{ item.alias ?: 'alias не задан' }} · ID {{ item.id }} +
+
+ {% if item.template_title %} +
{{ item.template_title }}ID {{ item.template_id }}
+ {% else %} + не назначен + {% endif %} +
+
+ {{ item.documents_count }} материалов + {{ item.fields_count }} полей + {{ item.requests_count }} подборок + {% if item.catalogs_count %}{{ item.catalogs_count }} каталогов{% endif %} +
+
+
+ {% if not item.documents_enabled %}создание выключено{% endif %} + {% if item.issues %} + {% for issue in item.issues %} + {{ issue.message|admin_trans }} + {% endfor %} + {% else %} + настроено + {% endif %} +
+
+
+ + +
+
Типы контента не найдены.
+
+
+ +
+
+ +
+
+
+
+
Проверка структуры
+

Диагностика

+

Подсказки ведут в штатные редакторы и ничего не исправляют автоматически.

+
+
+
+
+
+ +

Найденные замечания

Показаны проблемы текущей карты структуры

+
+ {{ diagnostics.items|length }} +
+
+ {% for issue in diagnostics.items %} + + + {{ issue.title }}{{ issue.message|admin_trans }} + + + {% else %} +
Явных проблем в текущей карте нет.
+ {% endfor %} +
+
+
+ +
+
+
+
+
Штатные инструменты
+

Редакторы компонентов

+

Структура показывает связи, а содержимое меняется в привычных разделах.

+
+
+ +
+{% endblock %} diff --git a/adminx/modules/PublicSite/view/map.twig b/adminx/modules/PublicSite/view/map.twig new file mode 100644 index 0000000..75b9386 --- /dev/null +++ b/adminx/modules/PublicSite/view/map.twig @@ -0,0 +1,84 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Карта сайта{% endblock %} +{% block content %} + +{% macro branch(items, base) %} + {% import _self as map %} +
    + {% for item in items %} +
  • +
    + + {{ item.title ?: item.document_title ?: 'Пункт #' ~ item.id }}{{ item.document_alias ?: item.alias ?: 'без URL' }} + {% if item.document_id > 0 %}{% endif %} +
    + {% if item.children %}{{ map.branch(item.children, base) }}{% endif %} +
  • + {% endfor %} +
+{% endmacro %} + +{% import _self as map %} + + + + + +{% set public_site_tab = 'map' %} +{% include '@public_site/_tabs.twig' %} + +
+
{{ site_map.stats.menus }}Меню
+
{{ site_map.stats.items }}Пунктов
+
{{ site_map.stats.linked_documents }}Связано документов
+
{{ site_map.stats.orphans }}Вне меню
+
+ +
+
+
+
Навигация

Дерево страниц

Карта ничего не меняет в публичной части и ведёт в штатные редакторы.

+
+
+
+
+ + {% if filters.q %}Сбросить{% endif %} +
+
+ {% for menu in site_map.menus %} +
+

{{ menu.title }}

{{ menu.alias }} · {{ menu.items_count }} пунктов

+ {% if menu.tree %}{{ map.branch(menu.tree, ADMINX_BASE) }}{% else %}

Подходящих пунктов нет.

{% endif %} +
+ {% else %} +
Меню пока нетСоздайте навигацию и добавьте пункты.
+ {% endfor %} +
+
+
+ +
+
+
+
Контроль

Документы вне меню

Это не обязательно ошибка: такие страницы могут открываться из подборок, каталогов и прямых ссылок.

+
+
+
+ {% for item in site_map.orphans %} + {{ item.title }}{{ item.rubric_title }} · /{{ item.alias }}{{ item.active ? 'опубликован' : 'черновик' }} + {% else %}
Документов вне меню не найдено
{% endfor %} +
+
+
+ +{% endblock %} diff --git a/adminx/modules/PublicSite/view/placements.twig b/adminx/modules/PublicSite/view/placements.twig new file mode 100644 index 0000000..e2643f2 --- /dev/null +++ b/adminx/modules/PublicSite/view/placements.twig @@ -0,0 +1,242 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Размещения · Публичный сайт{% endblock %} +{% block content %} + + + + + +{% set public_site_tab = 'placements' %} +{% include '@public_site/_tabs.twig' %} + +{% if rebuilt %} +
+ +
Карта обновлена

Все сохранённые шаблоны и ссылки на компоненты прочитаны заново.

+
+{% endif %} + +
+
+ +
{{ placement_map.stats.total }}Подключений
+
+
+ +
{{ placement_map.stats.sources }}Источников
+
+
+ +
{{ placement_map.stats.modules }}Модульных тегов
+
+
+ +
{{ placement_map.stats.broken }}Битых ссылок
+
+
+ +
+ +
+ Это безопасная карта действующего сайта +

Она читает уже сохранённые теги и ничего не переключает в публичной части. Изменения по-прежнему выполняются в штатных редакторах.

+
+
+ +{% set filter_suffix = '&q=' ~ filters.q|url_encode ~ '&area=' ~ filters.area|url_encode ~ '&type=' ~ filters.type|url_encode ~ '&state=' ~ filters.state|url_encode %} + + +{% if placement_view == 'placements' %} + {% set section_icon = 'ti-route-square' %} + {% set section_eyebrow = 'Области страницы' %} + {% set section_title = 'Подключённые компоненты' %} + {% set section_desc = placement_map.stats.filtered ~ ' связей соответствует текущему фильтру.' %} +{% else %} + {% set section_icon = 'ti-link' %} + {% set section_eyebrow = 'Где используется' %} + {% set section_title = 'Компоненты в текущей выборке' %} + {% set section_desc = 'Сводка помогает найти повторно используемые и потерянные компоненты.' %} +{% endif %} + +
+
+
+
+
{{ section_eyebrow }}
+

{{ section_title }}

+

{{ section_desc }}

+
+
+ +
+
+ + + + + + + {% if filters.q or filters.area or filters.type or filters.state %} + Сбросить + {% endif %} +
+
+ +{% if placement_view == 'placements' %} +
+
+ + + + + + + + + + + + + + + + + + + {% for item in placement_map.rows %} + + + + + + + + {% else %} + + + + {% endfor %} + +
ОбластьГде подключеноКомпонентСостояние
+
+ {{ item.area_title }} + {{ item.source_part_title }} +
+
+
+ {{ item.source_title ?: item.source_type_title ~ ' #' ~ item.source_id }} + {{ item.source_type_title }} · ID {{ item.source_id }} +
+
+
+ {{ item.component_title }} + {{ item.tag }} + {% if item.occurrences > 1 %}Повторяется {{ item.occurrences }} раза{% endif %} +
+
+ {% set state_class = item.component_status == 'broken' ? 'badge-red' : (item.component_status == 'disabled' ? 'badge-gray' : (item.component_status == 'module' ? 'badge-violet' : 'badge-green')) %} + {{ item.component_status_title }} + +
+ {% if item.source_url %} + + {% endif %} + {% if item.component_url %} + + {% endif %} +
+
+
+ + Размещений не найдено + Измените фильтры или добавьте компонент штатным тегом в шаблон. +
+
+
+ {% include '@public_site/_placement_pagination.twig' %} +
+{% else %} +
+
+ + + + + + + + + + + + + {% for item in placement_map.usage %} + + + + + + + + + {% else %} + + {% endfor %} + +
КомпонентТипПодключенийИсточниковСостояние
+
+ {{ item.component_title }} + {{ item.component_key }} +
+
{{ placement_map.options.types[item.component_type]|default(item.component_type) }}{{ item.placements }}{{ item.sources }} + {% set state_class = item.component_status == 'broken' ? 'badge-red' : (item.component_status == 'disabled' ? 'badge-gray' : (item.component_status == 'module' ? 'badge-violet' : 'badge-green')) %} + {{ item.component_status_title }} + + {% if item.component_url %} +
+ +
+ {% endif %} +
Компонентов в текущей выборке нет.
+
+ {% include '@public_site/_placement_pagination.twig' %} +
+{% endif %} +
+{% endblock %} diff --git a/adminx/modules/PublicSite/view/presentations.twig b/adminx/modules/PublicSite/view/presentations.twig new file mode 100644 index 0000000..4d783ee --- /dev/null +++ b/adminx/modules/PublicSite/view/presentations.twig @@ -0,0 +1,200 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Представления{% endblock %} +{% block content %} + + + + + +{% set public_site_tab = 'presentations' %} +{% include '@public_site/_tabs.twig' %} + +{% if not available %} +
Хранилище ещё не создано

Примените миграцию раздела «Публичный сайт».

+{% endif %} + +
+
{{ stats.total }}Представлений
+
{{ stats.published }}Опубликовано
+
{{ stats.changed }}Есть изменения
+
{{ stats.used }}Назначено
+
+ +
+
+
+
Оформление контента

Общие представления

Черновик и предпросмотр не меняют действующий публичный сайт.

+
+ +
+
+
+ + + + {% if filters.q or filters.kind or filters.state %}Сбросить{% endif %} +
+
+ + + + + {% for item in presentations %} + + + + + + + + + {% else %}{% endfor %} + +
IDНазваниеВидСостояниеИзменено
{{ item.id }}
{{ item.title }}{{ item.code }}{% if item.usage_count %} · назначений: {{ item.usage_count }}{% endif %}
{{ item.kind_label }} + {% if item.is_published %} + {{ item.has_changes ? 'Изменён черновик' : 'Опубликовано' }} + версия {{ item.version }} + {% else %}Черновик{% endif %} + {{ item.updated_label }}
+ + + {% if can_manage %} + + + {% endif %} +
Представлений пока нет.
+
+
+
+ +{% if legacy_sources %} +
+
+
+
Совместимость

Действующие редакторы

Они продолжают управлять текущим выводом и не копируются автоматически.

+
+
+ {% for source in legacy_sources %} + {{ source.title }}{{ source.count }} представлений + {% endfor %} +
+
+{% endif %} + +{% if can_manage and available %} + + + + + + +{% endif %} +{% endblock %} diff --git a/adminx/modules/Registrations/Controller.php b/adminx/modules/Registrations/Controller.php deleted file mode 100644 index fa47087..0000000 --- a/adminx/modules/Registrations/Controller.php +++ /dev/null @@ -1,161 +0,0 @@ - - | @copyright 2007-2026 (c) AVE.cms - | @link https://ave-cms.ru - | @version 3.3 - */ - - namespace App\Adminx\Registrations; - - defined('BASEPATH') || die('Direct access to this location is not allowed.'); - - use App\Common\Controller as BaseController; - use App\Common\Permission; - use App\Helpers\Request; - use App\Helpers\Response; - - class Controller extends BaseController - { - public function index(array $params = array()) - { - if (!Permission::check('view_registrations')) { - Response::forbidden(); - return ''; - } - - $filters = array( - 'status' => Request::getStr('status', ''), - 'risk_class' => Request::getStr('risk_class', ''), - 'q' => Request::getStr('q', ''), - ); - - return $this->render('@registrations/index.twig', array( - 'items' => Model::all($filters), - 'summary' => Model::summary(), - 'statuses' => Model::statuses(), - 'risk_classes' => Model::riskClasses(), - 'filters' => $filters, - 'can_manage' => Permission::check('manage_registrations'), - )); - } - - public function create(array $params = array()) - { - if (!Permission::check('manage_registrations')) { - Response::forbidden(); - return ''; - } - - return $this->render('@registrations/edit.twig', array( - 'item' => null, - 'statuses' => Model::statuses(), - 'risk_classes' => Model::riskClasses(), - )); - } - - public function edit(array $params = array()) - { - if (!Permission::check('view_registrations')) { - Response::forbidden(); - return ''; - } - - $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); - if (!$item) { - Response::notFound(); - return ''; - } - - return $this->render('@registrations/edit.twig', array( - 'item' => $item, - 'statuses' => Model::statuses(), - 'risk_classes' => Model::riskClasses(), - 'can_manage' => Permission::check('manage_registrations'), - )); - } - - public function store(array $params = array()) - { - if (($error = $this->manageGuard()) !== null) { - return $error; - } - - try { - $item = Model::create($this->input()); - return $this->success('Удостоверение добавлено', array( - 'data' => array('id' => $item['id'], 'redirect' => $this->base() . '/registrations'), - )); - } catch (\Throwable $e) { - return $this->error($e->getMessage(), array('number' => $e->getMessage()), 422); - } - } - - public function update(array $params = array()) - { - if (($error = $this->manageGuard()) !== null) { - return $error; - } - - try { - $item = Model::update(isset($params['id']) ? (int) $params['id'] : 0, $this->input()); - if (!$item) { - return $this->error('Удостоверение не найдено', array(), 404); - } - - return $this->success('Сохранено', array('data' => array('id' => $item['id']))); - } catch (\Throwable $e) { - return $this->error($e->getMessage(), array('number' => $e->getMessage()), 422); - } - } - - public function delete(array $params = array()) - { - if (($error = $this->manageGuard()) !== null) { - return $error; - } - - $deleted = Model::delete(isset($params['id']) ? (int) $params['id'] : 0); - if (!$deleted) { - return $this->error('Удостоверение не найдено', array(), 404); - } - - return $this->success('Удостоверение удалено', array( - 'data' => array('summary' => Model::summary()), - )); - } - - protected function input() - { - return array( - 'number' => Request::postStr('number', ''), - 'holder' => Request::postStr('holder', ''), - 'product_name' => Request::postStr('product_name', ''), - 'risk_class' => Request::postStr('risk_class', ''), - 'status' => Request::postStr('status', 'active'), - 'document_id' => Request::postInt('document_id', 0), - 'scan_url' => Request::postStr('scan_url', ''), - 'issued_at' => Request::postStr('issued_at', ''), - 'valid_until' => Request::postStr('valid_until', ''), - 'perpetual' => Request::postInt('perpetual', 0), - 'note' => Request::postStr('note', ''), - ); - } - - protected function manageGuard() - { - if (($error = $this->csrfGuard()) !== null) { - return $error; - } - - return Permission::check('manage_registrations') - ? null - : $this->error('Недостаточно прав', array(), 403); - } - } diff --git a/adminx/modules/Registrations/Model.php b/adminx/modules/Registrations/Model.php deleted file mode 100644 index e69e387..0000000 --- a/adminx/modules/Registrations/Model.php +++ /dev/null @@ -1,269 +0,0 @@ - - | @copyright 2007-2026 (c) AVE.cms - | @link https://ave-cms.ru - | @version 3.3 - */ - - namespace App\Adminx\Registrations; - - defined('BASEPATH') || die('Direct access to this location is not allowed.'); - - use DB; - - /** - * Реестр регистрационных удостоверений Росздравнадзора (РУ) на медизделия. - * - * Юридически обязателен для продажи медтехники. Мягкая связь с товаром - * (document_id nullable) — жёсткий FK не нужен, товар может быть удалён/заменён. - */ - class Model - { - /** Статусы РУ: code => [label, color]. */ - public static function statuses() - { - return array( - 'active' => array('label' => 'Действует', 'color' => 'green'), - 'suspended' => array('label' => 'Приостановлено', 'color' => 'amber'), - 'expired' => array('label' => 'Истекло', 'color' => 'gray'), - 'cancelled' => array('label' => 'Аннулировано', 'color' => 'red'), - ); - } - - /** Классы риска медизделий по номенклатуре Росздравнадзора. */ - public static function riskClasses() - { - return array('1', '2a', '2b', '3'); - } - - public static function normalizeStatus($status, $fallback = 'active') - { - $status = trim((string) $status); - return array_key_exists($status, self::statuses()) ? $status : $fallback; - } - - public static function normalizeRisk($risk) - { - $risk = trim((string) $risk); - return in_array($risk, self::riskClasses(), true) ? $risk : ''; - } - - public static function table() - { - return Schema::table('registration_certificates'); - } - - /** Список с фильтрами: status, risk_class, q (номер/владелец/изделие). */ - public static function all(array $filters = array()) - { - $sql = 'SELECT * FROM ' . self::table() . ' WHERE 1=1'; - $args = array(); - - $status = isset($filters['status']) ? self::normalizeStatusFilter($filters['status']) : ''; - if ($status !== '') { - $sql .= ' AND status = %s'; - $args[] = $status; - } - - $risk = isset($filters['risk_class']) ? self::normalizeRisk($filters['risk_class']) : ''; - if ($risk !== '') { - $sql .= ' AND risk_class = %s'; - $args[] = $risk; - } - - $q = isset($filters['q']) ? trim((string) $filters['q']) : ''; - if ($q !== '') { - $sql .= ' AND (number LIKE %ss OR holder LIKE %ss OR product_name LIKE %ss)'; - $args[] = $q; - $args[] = $q; - $args[] = $q; - } - - $sql .= ' ORDER BY updated_at DESC, id DESC'; - $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); - - return array_map(array(self::class, 'normalize'), $rows ?: array()); - } - - public static function summary() - { - $row = DB::query( - 'SELECT COUNT(*) AS total,' - . ' SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS active,' - . ' SUM(CASE WHEN status <> %s AND status <> %s THEN 1 ELSE 0 END) AS inactive' - . ' FROM ' . self::table(), - 'active', - 'active', - 'expired' - )->getAssoc(); - - // Действующие РУ со сроком, истекающим в ближайшие 60 дней. - $expiring = (int) DB::query( - 'SELECT COUNT(*) FROM ' . self::table() - . ' WHERE status = %s AND perpetual = 0 AND valid_until > 0 AND valid_until < %i', - 'active', - strtotime('+60 days') - )->getValue(); - - return array( - 'total' => isset($row['total']) ? (int) $row['total'] : 0, - 'active' => isset($row['active']) ? (int) $row['active'] : 0, - 'inactive' => isset($row['inactive']) ? (int) $row['inactive'] : 0, - 'expiring' => $expiring, - ); - } - - public static function one($id) - { - $row = DB::query('SELECT * FROM ' . self::table() . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc(); - return $row ? self::normalize($row) : null; - } - - public static function create(array $data) - { - $fields = self::sanitize($data); - $now = time(); - $fields['created_at'] = $now; - $fields['updated_at'] = $now; - DB::Insert(self::table(), $fields); - return self::one((int) DB::insertId()); - } - - public static function update($id, array $data) - { - if (!self::one($id)) { - return null; - } - - $fields = self::sanitize($data); - $fields['updated_at'] = time(); - DB::Update(self::table(), $fields, 'id = %i', (int) $id); - return self::one($id); - } - - public static function delete($id) - { - if (!self::one($id)) { - return false; - } - - DB::Delete(self::table(), 'id = %i', (int) $id); - return true; - } - - protected static function sanitize(array $data) - { - $number = trim((string) (isset($data['number']) ? $data['number'] : '')); - if ($number === '') { - throw new \RuntimeException('Укажите номер РУ'); - } - - $product = trim((string) (isset($data['product_name']) ? $data['product_name'] : '')); - if ($product === '') { - throw new \RuntimeException('Укажите наименование медизделия'); - } - - $perpetual = !empty($data['perpetual']) ? 1 : 0; - $validUntil = $perpetual ? 0 : self::dateToTs(isset($data['valid_until']) ? $data['valid_until'] : ''); - - return array( - 'number' => mb_substr($number, 0, 120, 'UTF-8'), - 'holder' => mb_substr(trim((string) (isset($data['holder']) ? $data['holder'] : '')), 0, 255, 'UTF-8'), - 'product_name' => mb_substr($product, 0, 255, 'UTF-8'), - 'risk_class' => self::normalizeRisk(isset($data['risk_class']) ? $data['risk_class'] : ''), - 'status' => self::normalizeStatus(isset($data['status']) ? $data['status'] : 'active'), - 'document_id' => self::nullableId(isset($data['document_id']) ? $data['document_id'] : null), - 'scan_url' => self::url(isset($data['scan_url']) ? $data['scan_url'] : ''), - 'issued_at' => self::dateToTs(isset($data['issued_at']) ? $data['issued_at'] : ''), - 'valid_until' => $validUntil, - 'perpetual' => $perpetual, - 'note' => mb_substr(trim((string) (isset($data['note']) ? $data['note'] : '')), 0, 2000, 'UTF-8'), - ); - } - - public static function normalize($row) - { - $row = (array) $row; - $statuses = self::statuses(); - $status = self::normalizeStatus(isset($row['status']) ? $row['status'] : ''); - - $row['id'] = (int) $row['id']; - $row['number'] = (string) $row['number']; - $row['holder'] = (string) $row['holder']; - $row['product_name'] = (string) $row['product_name']; - $row['risk_class'] = (string) $row['risk_class']; - $row['status'] = $status; - $row['status_label'] = $statuses[$status]['label']; - $row['status_color'] = $statuses[$status]['color']; - $row['document_id'] = isset($row['document_id']) && $row['document_id'] !== null ? (int) $row['document_id'] : null; - $row['scan_url'] = (string) $row['scan_url']; - $row['issued_at'] = (int) $row['issued_at']; - $row['valid_until'] = (int) $row['valid_until']; - $row['perpetual'] = (int) $row['perpetual'] === 1; - $row['issued_label'] = self::tsLabel($row['issued_at']); - $row['issued_input'] = self::tsInput($row['issued_at']); - $row['valid_input'] = self::tsInput($row['valid_until']); - $row['valid_label'] = $row['perpetual'] ? 'бессрочно' : self::tsLabel($row['valid_until']); - $row['is_expiring'] = !$row['perpetual'] && $row['valid_until'] > 0 - && $row['valid_until'] < strtotime('+60 days') && $status === 'active'; - $row['note'] = (string) $row['note']; - return $row; - } - - protected static function normalizeStatusFilter($status) - { - $status = trim((string) $status); - return array_key_exists($status, self::statuses()) ? $status : ''; - } - - protected static function nullableId($value) - { - $value = (int) $value; - return $value > 0 ? $value : null; - } - - protected static function url($value) - { - $value = trim((string) $value); - if ($value === '') { - return ''; - } - - // Разрешаем только http(s) и локальные пути; прочее отбрасываем. - if (preg_match('#^https?://#i', $value) || strpos($value, '/') === 0) { - return mb_substr($value, 0, 500, 'UTF-8'); - } - - return ''; - } - - protected static function dateToTs($value) - { - $value = trim((string) $value); - if ($value === '') { - return 0; - } - - $ts = strtotime($value); - return $ts === false ? 0 : $ts; - } - - protected static function tsLabel($ts) - { - $ts = (int) $ts; - return $ts > 0 ? date('d.m.Y', $ts) : ''; - } - - protected static function tsInput($ts) - { - $ts = (int) $ts; - return $ts > 0 ? date('Y-m-d', $ts) : ''; - } - } diff --git a/adminx/modules/Registrations/Schema.php b/adminx/modules/Registrations/Schema.php deleted file mode 100644 index 80ffd7e..0000000 --- a/adminx/modules/Registrations/Schema.php +++ /dev/null @@ -1,42 +0,0 @@ - - | @copyright 2007-2026 (c) AVE.cms - | @link https://ave-cms.ru - | @version 3.3 - */ - - namespace App\Adminx\Registrations; - - defined('BASEPATH') || die('Direct access to this location is not allowed.'); - - use App\Common\PublicConfiguration; - - /** - * Резолвер таблиц модуля «Регистрационные удостоверения». - * - * Данные принадлежат удаляемому пакету → модульный префикс (тот же домен, что в - * миграции: {{module_prefix}}_registration_*). Живёт внутри /adminx. - */ - class Schema - { - protected static $allowed = array( - 'registration_certificates', - ); - - public static function table($suffix) - { - $suffix = (string) $suffix; - if (!in_array($suffix, self::$allowed, true)) { - throw new \InvalidArgumentException('Некорректная таблица регистрационных удостоверений'); - } - - return PublicConfiguration::prefix('module') . '_' . $suffix; - } - } diff --git a/adminx/modules/Registrations/assets/registrations.css b/adminx/modules/Registrations/assets/registrations.css deleted file mode 100644 index 08d7fc4..0000000 --- a/adminx/modules/Registrations/assets/registrations.css +++ /dev/null @@ -1,80 +0,0 @@ -/* Модуль «Регистрационные удостоверения»: реестр и форма (оформление AdminKit). */ -.registrations-summary { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 14px; - margin-bottom: 18px; -} -@media (max-width: 1100px) { - .registrations-summary { - grid-template-columns: repeat(2, 1fr); - } -} -@media (max-width: 560px) { - .registrations-summary { - grid-template-columns: 1fr; - } -} -.registrations-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - flex-wrap: wrap; -} -.registrations-filters { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} -.registrations-filters .input-icon { - flex: 1 1 220px; - min-width: 180px; -} -.registrations-number { - display: flex; - flex-direction: column; - line-height: 1.35; -} -.registrations-number small { - color: var(--text-secondary, #64748b); -} -.registrations-table .ta-right { - text-align: right; -} -.registrations-table .mono { - font-variant-numeric: tabular-nums; -} -.registrations-form { - padding: 20px 22px 24px; -} -.registrations-form .form-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px 18px; - margin-bottom: 8px; -} -.registrations-form .field-wide { - grid-column: 1 / -1; -} -.registrations-form .field-check { - display: flex; - align-items: center; - gap: 10px; - align-self: end; - padding-bottom: 6px; -} -@media (max-width: 720px) { - .registrations-form .form-grid { - grid-template-columns: 1fr; - } -} -.registrations-form-actions { - display: flex; - gap: 10px; - align-items: center; - margin-top: 18px; - padding-top: 16px; - border-top: 1px solid var(--border, #e2e8f0); -} diff --git a/adminx/modules/Registrations/assets/registrations.js b/adminx/modules/Registrations/assets/registrations.js deleted file mode 100644 index a024ac8..0000000 --- a/adminx/modules/Registrations/assets/registrations.js +++ /dev/null @@ -1,96 +0,0 @@ -/* -|-------------------------------------------------------------------------------------- -| AVE.cms — модуль «Регистрационные удостоверения» (adminx) -|-------------------------------------------------------------------------------------- -| Adminx.Registrations: сабмит формы, «бессрочно», удаление — единый Ajax/Toast/Confirm. -*/ -(function () { - 'use strict'; - - if (typeof window.Adminx === 'undefined') { return; } - var Adminx = window.Adminx; - - Adminx.Registrations = { - init: function () { - this.bindPerpetual(); - this.bindForm(); - this.bindDelete(); - }, - - bindPerpetual: function () { - var toggle = document.querySelector('[data-reg-perpetual]'); - var valid = document.querySelector('[data-reg-valid]'); - if (!toggle || !valid) { return; } - var sync = function () { valid.disabled = toggle.checked; if (toggle.checked) { valid.value = ''; } }; - toggle.addEventListener('change', sync); - sync(); - }, - - bindForm: function () { - var form = document.querySelector('[data-reg-form]'); - if (!form) { return; } - - form.addEventListener('submit', function (event) { - event.preventDefault(); - var submit = form.querySelector('[type="submit"]'); - if (submit) { submit.disabled = true; } - - Adminx.Ajax.post(form.getAttribute('data-action'), new FormData(form)).then(function (res) { - var body = res.data || {}; - if (res.ok && body.success) { - Adminx.Toast.show(body.message || 'Сохранено', 'success'); - var redirect = form.getAttribute('data-redirect') || (body.data && body.data.redirect); - if (redirect) { - window.setTimeout(function () { window.location.href = redirect; }, 350); - } - return; - } - - Adminx.Toast.show(body.message || 'Не удалось сохранить', 'error'); - }).catch(function () { - Adminx.Toast.show('Сервер недоступен. Повторите попытку.', 'error'); - }).then(function () { - if (submit) { submit.disabled = false; } - }); - }); - }, - - bindDelete: function () { - document.addEventListener('click', function (event) { - var trigger = event.target.closest('[data-reg-delete]'); - if (!trigger) { return; } - event.preventDefault(); - - var id = trigger.getAttribute('data-reg-delete'); - Adminx.Confirm.open({ - kind: 'error', - title: 'Удалить удостоверение?', - message: 'Запись будет удалена из реестра без возможности восстановления.', - confirmLabel: 'Удалить', - confirmClass: 'btn-danger', - onConfirm: function () { - Adminx.Ajax.post(Adminx.base() + '/registrations/' + id + '/delete', new FormData()).then(function (res) { - var body = res.data || {}; - if (res.ok && body.success) { - var row = document.querySelector('tr[data-id="' + id + '"]'); - if (row) { row.remove(); } - Adminx.Toast.show(body.message || 'Удалено', 'success'); - return; - } - - Adminx.Toast.show(body.message || 'Не удалось удалить', 'error'); - }).catch(function () { - Adminx.Toast.show('Сервер недоступен. Повторите попытку.', 'error'); - }); - } - }); - }); - } - }; - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', function () { Adminx.Registrations.init(); }); - } else { - Adminx.Registrations.init(); - } -})(); diff --git a/adminx/modules/Registrations/language/en/client.xml b/adminx/modules/Registrations/language/en/client.xml deleted file mode 100644 index effc451..0000000 --- a/adminx/modules/Registrations/language/en/client.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - The entry will be deleted from the registry without the possibility of recovery. - Failed to delete - Delete - The server is unavailable. Please try again. - Failed to save - Deleted - Delete ID? - Saved - Saved - diff --git a/adminx/modules/Registrations/language/en/interface.xml b/adminx/modules/Registrations/language/en/interface.xml deleted file mode 100644 index 444e6ec..0000000 --- a/adminx/modules/Registrations/language/en/interface.xml +++ /dev/null @@ -1,71 +0,0 @@ - - - https://… or /media/… - Product (document) ID - indefinitely - Owner - RU owner/manufacturer - Total RU - Issued - Registration date - Action - Valid until - Valid - Add RU - ≤60 days expire. - To the register - Risk class - Comment - Medical product - Name of medical product * - New - New ID - RU number - RU number * - Number, owner, product... - Open scan - Suspended/cancelled - Reg. certificates - Registration certificate of Roszdravnadzor for a medical device. - Registration certificates - Register of RU - Register of Roszdravnadzor for medical devices and their validity periods. - RZN 2020/12345 - RU - Communication and document - scan attached - Deadline - Deadlines - Link to scan of RU - Product and scan - Certificate - No identities have been added yet. - Filter by status, risk class and search by number, owner, product. - - not specified - - Canceled - Valid - The entry will be deleted from the registry without the possibility of recovery. - Expired - Catalog - Failed to save - Failed to delete - Insufficient rights - Incorrect registration certificate table - Suspended - View the register of registration certificates. - Reg. credentials: view - Reg. credentials: management - Register of registration certificates of Roszdravnadzor - Register of Roszdravnadzor for medical devices: risk class, validity period, connection with the product. - The server is unavailable. Please try again. - Creating, changing and deleting registration certificates. - Saved - Deleted - Delete - Delete ID? - Identity added - Identity not found - Identity deleted - Please indicate the name of the medical product - Specify RU number - diff --git a/adminx/modules/Registrations/language/en/runtime.xml b/adminx/modules/Registrations/language/en/runtime.xml deleted file mode 100644 index 6045999..0000000 --- a/adminx/modules/Registrations/language/en/runtime.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - Canceled - Valid - Expired - Catalog - Insufficient rights - Incorrect registration certificate table - Suspended - View the register of registration certificates. - Reg. certificates - Reg. credentials: view - Reg. credentials: management - Registration certificates - Register of Roszdravnadzor for medical devices: risk class, validity period, connection with the product. - Register of registration certificates of Roszdravnadzor - Creating, changing and deleting registration certificates. - Saved - Identity added - Identity not found - Identity deleted - Please indicate the name of the medical product - Specify RU number - indefinitely - diff --git a/adminx/modules/Registrations/language/ru/client.xml b/adminx/modules/Registrations/language/ru/client.xml deleted file mode 100644 index 34bbd16..0000000 --- a/adminx/modules/Registrations/language/ru/client.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - Запись будет удалена из реестра без возможности восстановления. - Не удалось удалить - Удалить - Сервер недоступен. Повторите попытку. - Не удалось сохранить - Удалено - Удалить удостоверение? - Сохранено - Сохранено - diff --git a/adminx/modules/Registrations/language/ru/interface.xml b/adminx/modules/Registrations/language/ru/interface.xml deleted file mode 100644 index aa860cb..0000000 --- a/adminx/modules/Registrations/language/ru/interface.xml +++ /dev/null @@ -1,71 +0,0 @@ - - - https://… или /media/… - ID товара (документа) - бессрочно - Владелец - Владелец РУ / производитель - Всего РУ - Выдано - Дата регистрации - Действие - Действует до - Действуют - Добавить РУ - Истекают ≤60 дн. - К реестру - Класс риска - Комментарий - Медизделие - Наименование медизделия * - Новое - Новое удостоверение - Номер РУ - Номер РУ * - Номер, владелец, изделие… - Открыть скан - Приостановлены/аннулированы - Рег. удостоверения - Регистрационное удостоверение Росздравнадзора на медицинское изделие. - Регистрационные удостоверения - Реестр РУ - Реестр РУ Росздравнадзора на медизделия и их сроки действия. - РЗН 2020/12345 - РУ - Связь и документ - скан прикреплён - Срок - Сроки - Ссылка на скан РУ - Товар и скан - Удостоверение - Удостоверения ещё не добавлены. - Фильтр по статусу, классу риска и поиск по номеру, владельцу, изделию. - — не указан — - Аннулировано - Действует - Запись будет удалена из реестра без возможности восстановления. - Истекло - Каталог - Не удалось сохранить - Не удалось удалить - Недостаточно прав - Некорректная таблица регистрационных удостоверений - Приостановлено - Просмотр реестра регистрационных удостоверений. - Рег. удостоверения: просмотр - Рег. удостоверения: управление - Реестр регистрационных удостоверений Росздравнадзора - Реестр РУ Росздравнадзора на медизделия: класс риска, срок действия, связь с товаром. - Сервер недоступен. Повторите попытку. - Создание, изменение и удаление регистрационных удостоверений. - Сохранено - Удалено - Удалить - Удалить удостоверение? - Удостоверение добавлено - Удостоверение не найдено - Удостоверение удалено - Укажите наименование медизделия - Укажите номер РУ - diff --git a/adminx/modules/Registrations/language/ru/runtime.xml b/adminx/modules/Registrations/language/ru/runtime.xml deleted file mode 100644 index 5ce5f84..0000000 --- a/adminx/modules/Registrations/language/ru/runtime.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - Аннулировано - Действует - Истекло - Каталог - Недостаточно прав - Некорректная таблица регистрационных удостоверений - Приостановлено - Просмотр реестра регистрационных удостоверений. - Рег. удостоверения - Рег. удостоверения: просмотр - Рег. удостоверения: управление - Регистрационные удостоверения - Реестр РУ Росздравнадзора на медизделия: класс риска, срок действия, связь с товаром. - Реестр регистрационных удостоверений Росздравнадзора - Создание, изменение и удаление регистрационных удостоверений. - Сохранено - Удостоверение добавлено - Удостоверение не найдено - Удостоверение удалено - Укажите наименование медизделия - Укажите номер РУ - бессрочно - diff --git a/adminx/modules/Registrations/migrations/001_create_registration_certificates.sql b/adminx/modules/Registrations/migrations/001_create_registration_certificates.sql deleted file mode 100644 index b102dfa..0000000 --- a/adminx/modules/Registrations/migrations/001_create_registration_certificates.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE IF NOT EXISTS `{{module_prefix}}_registration_certificates` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `number` VARCHAR(120) NOT NULL DEFAULT '', - `holder` VARCHAR(255) NOT NULL DEFAULT '', - `product_name` VARCHAR(255) NOT NULL DEFAULT '', - `risk_class` VARCHAR(4) NOT NULL DEFAULT '', - `status` VARCHAR(20) NOT NULL DEFAULT 'active', - `document_id` INT UNSIGNED NULL DEFAULT NULL, - `scan_url` VARCHAR(500) NOT NULL DEFAULT '', - `issued_at` INT UNSIGNED NOT NULL DEFAULT 0, - `valid_until` INT UNSIGNED NOT NULL DEFAULT 0, - `perpetual` TINYINT(1) NOT NULL DEFAULT 0, - `note` TEXT NOT NULL, - `created_at` INT UNSIGNED NOT NULL DEFAULT 0, - `updated_at` INT UNSIGNED NOT NULL DEFAULT 0, - PRIMARY KEY (`id`), - KEY `idx_reg_status` (`status`, `updated_at`), - KEY `idx_reg_risk` (`risk_class`), - KEY `idx_reg_document` (`document_id`), - KEY `idx_reg_number` (`number`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Registrations/migrations/002_register_registration_permissions.sql b/adminx/modules/Registrations/migrations/002_register_registration_permissions.sql deleted file mode 100644 index a472ab7..0000000 --- a/adminx/modules/Registrations/migrations/002_register_registration_permissions.sql +++ /dev/null @@ -1,8 +0,0 @@ -INSERT INTO `{{prefix}}_permissions` - (`code`, `module`, `group_code`, `name`, `description`, `sort_order`) -VALUES - ('view_registrations', 'registrations', 'navigation', 'Рег. удостоверения: просмотр', 'Просмотр реестра регистрационных удостоверений.', 10), - ('manage_registrations', 'registrations', 'content', 'Рег. удостоверения: управление', 'Создание, изменение и удаление регистрационных удостоверений.', 20) -ON DUPLICATE KEY UPDATE - `module` = VALUES(`module`), `group_code` = VALUES(`group_code`), - `name` = VALUES(`name`), `description` = VALUES(`description`), `sort_order` = VALUES(`sort_order`); diff --git a/adminx/modules/Registrations/migrations/uninstall.sql b/adminx/modules/Registrations/migrations/uninstall.sql deleted file mode 100644 index cec290e..0000000 --- a/adminx/modules/Registrations/migrations/uninstall.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS `{{module_prefix}}_registration_certificates`; -DELETE FROM `{{prefix}}_permissions` WHERE `module` = 'registrations'; diff --git a/adminx/modules/Registrations/module.php b/adminx/modules/Registrations/module.php deleted file mode 100644 index 3da9899..0000000 --- a/adminx/modules/Registrations/module.php +++ /dev/null @@ -1,88 +0,0 @@ - - | @copyright 2007-2026 (c) AVE.cms - | @link https://ave-cms.ru - | @version 3.3 - */ - - defined('BASEPATH') || die('Direct access to this location is not allowed.'); - - return array( - 'code' => 'registrations', - 'name' => 'Регистрационные удостоверения', - 'version' => '1.0.0', - 'description' => 'Реестр РУ Росздравнадзора на медизделия: класс риска, срок действия, связь с товаром.', - 'author' => 'AVE.cms', - 'lifecycle' => array( - 'managed' => true, - 'uninstall' => array('migrations/uninstall.sql'), - ), - 'package' => array('removable' => true), - 'permissions' => array( - 'key' => 'registrations', - 'items' => array( - array( - 'code' => 'view_registrations', - 'group_code' => 'navigation', - 'name' => 'Рег. удостоверения: просмотр', - 'description' => 'Просмотр реестра регистрационных удостоверений.', - 'sort_order' => 10, - ), - array( - 'code' => 'manage_registrations', - 'group_code' => 'content', - 'name' => 'Рег. удостоверения: управление', - 'description' => 'Создание, изменение и удаление регистрационных удостоверений.', - 'sort_order' => 20, - ), - ), - 'icon' => 'ti ti-file-certificate', - 'priority' => 41, - ), - 'routes' => array( - array('GET', '/registrations', array(\App\Adminx\Registrations\Controller::class, 'index')), - array('GET', '/registrations/create', array(\App\Adminx\Registrations\Controller::class, 'create')), - array('POST', '/registrations', array(\App\Adminx\Registrations\Controller::class, 'store')), - array('GET', '/registrations/{id}/edit', array(\App\Adminx\Registrations\Controller::class, 'edit')), - array('POST', '/registrations/{id}/delete', array(\App\Adminx\Registrations\Controller::class, 'delete')), - array('POST', '/registrations/{id}', array(\App\Adminx\Registrations\Controller::class, 'update')), - ), - 'migrations' => array( - array('id' => '001_create_registration_certificates', 'file' => 'migrations/001_create_registration_certificates.sql'), - array('id' => '002_register_registration_permissions', 'file' => 'migrations/002_register_registration_permissions.sql'), - ), - 'assets' => array( - 'styles' => array( - array('url' => ADMINX_BASE . '/modules/Registrations/assets/registrations.css', 'priority' => 41), - ), - 'scripts' => array( - array('url' => ADMINX_BASE . '/modules/Registrations/assets/registrations.js', 'priority' => 41), - ), - ), - 'admin_extension' => array( - 'url' => '/registrations', - 'icon' => 'ti ti-file-certificate', - 'feature' => 'Реестр регистрационных удостоверений Росздравнадзора', - 'menu' => array( - array( - 'code' => 'registrations', - 'label' => 'Рег. удостоверения', - 'url' => '/registrations', - 'icon' => 'ti ti-file-certificate', - 'permission' => 'view_registrations', - 'group' => 'Каталог', - 'sort_order' => 40, - 'match' => array( - '/registrations', - ), - ), - ), - ), - ); diff --git a/adminx/modules/Registrations/view/edit.twig b/adminx/modules/Registrations/view/edit.twig deleted file mode 100644 index 0fd8634..0000000 --- a/adminx/modules/Registrations/view/edit.twig +++ /dev/null @@ -1,60 +0,0 @@ -{% extends '@adminx/main.twig' %} -{% block title %}{{ item ? 'РУ ' ~ item.number : 'Новое удостоверение' }}{% endblock %} -{% block content %} -{% set can_edit = item is null or can_manage %} - - - - -
- - -
Удостоверение

Основные данные

-
-
-
- -
-
-
-
- -
-
- -
Сроки

Действие

-
-
-
-
- - Бессрочно -
-
- -
Связь и документ

Товар и скан

-
-
-
-
-
- - {% if can_edit %} -
- - Отмена -
- {% endif %} -
-{% endblock %} diff --git a/adminx/modules/Registrations/view/index.twig b/adminx/modules/Registrations/view/index.twig deleted file mode 100644 index ba514c4..0000000 --- a/adminx/modules/Registrations/view/index.twig +++ /dev/null @@ -1,76 +0,0 @@ -{% extends '@adminx/main.twig' %} -{% block title %}Регистрационные удостоверения{% endblock %} -{% block content %} - - - - -
-
{{ summary.total }}
Всего РУ
-
{{ summary.active }}
Действуют
-
{{ summary.expiring }}
Истекают ≤60 дн.
-
{{ summary.inactive }}
Приостановлены/аннулированы
-
- -
-
-
- -

Реестр РУ

Фильтр по статусу, классу риска и поиск по номеру, владельцу, изделию.

-
-
- - -
- - -
- - {% if filters.status or filters.risk_class or filters.q %}Сбросить{% endif %} -
-
- -
- - - - - - - {% for item in items %} - - - - - - - - - - - {% else %} - - {% endfor %} - -
Номер РУМедизделиеВладелецКлассВыданоСрокСтатус
- - {{ item.number }} - {% if item.scan_url %} скан прикреплён{% endif %} - - {{ item.product_name }}{{ item.holder ?: '—' }}{% if item.risk_class %}{{ item.risk_class }}{% else %}{% endif %}{{ item.issued_label ?: '—' }}{% if item.perpetual %}бессрочно{% else %}{{ item.valid_label ?: '—' }}{% endif %}{{ item.status_label }} - {% if item.scan_url %}{% endif %} - - {% if can_manage %}{% endif %} -
{% if filters.status or filters.risk_class or filters.q %}Ничего не найдено по фильтру.{% else %}Удостоверения ещё не добавлены.{% endif %}
-
-
-{% endblock %} diff --git a/adminx/modules/Requests/Model.php b/adminx/modules/Requests/Model.php index 0ddb8ef..8f4dbe0 100644 --- a/adminx/modules/Requests/Model.php +++ b/adminx/modules/Requests/Model.php @@ -553,10 +553,25 @@ public static function delete($id) { $current = self::find($id); + if (!$current) { + return false; + } + + $dependencies = \App\Content\ContentTagDependencies::request( + (int) $id, + isset($current->request_alias) ? (string) $current->request_alias : '' + ); + if ($dependencies) { + throw new \RuntimeException( + 'Запрос используется: ' . implode(', ', $dependencies) . '. Сначала уберите эти вызовы.' + ); + } + DB::Delete(self::cond(), 'request_id = %i', (int) $id); DB::Delete(self::groups(), 'request_id = %i', (int) $id); DB::Delete(self::req(), 'Id = %i', (int) $id); FileCacheInvalidator::request($id, $current ? $current->request_alias : ''); + return true; } // ---------------- Условия ---------------- @@ -694,41 +709,7 @@ public static function documentPicker($query, $rubricIds = '', $limit = 30) { - $limit = max(1, min(50, (int) $limit)); - $sql = 'SELECT d.Id,d.rubric_id,d.document_title,d.document_alias,r.rubric_title' - . ' FROM ' . ContentTables::table('documents') . ' d' - . ' LEFT JOIN ' . ContentTables::table('rubrics') . ' r ON r.Id=d.rubric_id' - . " WHERE d.document_deleted!='1'"; - $args = array(); - $query = trim((string) $query); - if ($query !== '') { - $sql .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id=%i)'; - $args[] = $query; - $args[] = $query; - $args[] = (int) $query; - } - - $ids = array(); - foreach (explode(',', (string) $rubricIds) as $rubricId) { - $rubricId = (int) trim($rubricId); - if ($rubricId > 0) { $ids[$rubricId] = $rubricId; } - } - - if ($ids) { $sql .= ' AND d.rubric_id IN (' . implode(',', $ids) . ')'; } - $sql .= ' ORDER BY d.document_changed DESC,d.Id DESC LIMIT ' . $limit; - $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); - $result = array(); - foreach ($rows as $row) { - $result[] = array( - 'id' => (int) $row['Id'], - 'title' => (string) $row['document_title'], - 'alias' => (string) $row['document_alias'], - 'rubric_id' => (int) $row['rubric_id'], - 'rubric_title' => (string) $row['rubric_title'], - ); - } - - return $result; + return (new \App\Content\Documents\DocumentPickerRepository())->search($query, $rubricIds, $limit); } public static function paginationOptions() diff --git a/adminx/modules/Requests/assets/requests.css b/adminx/modules/Requests/assets/requests.css index b58fd6b..df7dc02 100644 --- a/adminx/modules/Requests/assets/requests.css +++ b/adminx/modules/Requests/assets/requests.css @@ -211,7 +211,6 @@ color: var(--blue-700); font-size: 10px; font-weight: 700; - font-variant-numeric: tabular-nums; } .request-sort-rule > label { display: grid; @@ -714,7 +713,6 @@ .request-contract-group-head span { color: var(--text-muted); font-size: 11px; - font-variant-numeric: tabular-nums; } .request-contract-options { display: grid; @@ -1304,7 +1302,6 @@ body.req-code-fullscreen-open { } .request-condition-picker-id { color: var(--text-muted); - font-variant-numeric: tabular-nums; font-size: 11px; } .request-condition-picker-item > span:nth-child(2) { @@ -1569,7 +1566,6 @@ tr.cond-dragging { .request-preview-stats b { font-size: 18px; line-height: 22px; - font-variant-numeric: tabular-nums; } .request-preview-stats span { color: var(--text-muted); @@ -1702,7 +1698,6 @@ tr.cond-dragging { .request-preview-list-main small { color: var(--text-muted); font-size: 10px; - font-variant-numeric: tabular-nums; } .request-preview-list-values { display: grid; @@ -1765,7 +1760,6 @@ tr.cond-dragging { .request-preview-table-title small { color: var(--text-muted); font-size: 9.5px; - font-variant-numeric: tabular-nums; } .request-preview-table-action { width: 52px; @@ -1811,7 +1805,6 @@ tr.cond-dragging { .request-preview-item header small { color: var(--text-muted); font-size: 10px; - font-variant-numeric: tabular-nums; } .request-preview-values { display: grid; @@ -2380,10 +2373,6 @@ tr.cond-dragging { display: grid; gap: 18px; } -.request-audit-summary .stat-val, -.request-audit-progress span { - font-variant-numeric: tabular-nums; -} .request-audit-note { display: grid; grid-template-columns: auto minmax(0, 1fr); diff --git a/adminx/modules/Requests/module.php b/adminx/modules/Requests/module.php index 0e579c5..c0a43ce 100644 --- a/adminx/modules/Requests/module.php +++ b/adminx/modules/Requests/module.php @@ -54,7 +54,7 @@ 'icon' => 'ti ti-list-search', 'permission' => 'view_requests', 'group' => 'Контент', - 'sort_order' => 26, + 'sort_order' => 27, 'match' => array( '/requests', ), diff --git a/adminx/modules/Rubrics/Controller.php b/adminx/modules/Rubrics/Controller.php index 45760e5..e3cde67 100644 --- a/adminx/modules/Rubrics/Controller.php +++ b/adminx/modules/Rubrics/Controller.php @@ -28,7 +28,6 @@ use App\Content\Fields\FieldSettings; use App\Content\Fields\FieldConditionEvaluator; use App\Content\Fields\FieldSettingsForm; - use App\Content\Fields\FieldInstallationAudit; use App\Content\Documents\DocumentAliasTemplate; use App\Content\Rubrics\FieldSetRegistry; use DB; @@ -43,6 +42,7 @@ $q = Request::getStr('q', ''); $state = Request::getStr('state', ''); + $activeTab = Request::getStr('tab', '') === 'types' ? 'types' : 'rubrics'; $typeUsage = Model::fieldTypeUsage(); return $this->render('@rubrics/index.twig', array( @@ -52,16 +52,40 @@ 'templates' => Model::templateOptions(), 'field_type_usage' => $typeUsage, 'field_type_summary' => FieldTypes::summary($typeUsage), - 'field_installation_audit' => FieldInstallationAudit::report(null, false, $typeUsage), 'field_presets' => RubricFieldPresets::all(), 'rubric_alias_tokens' => DocumentAliasTemplate::tokens(), 'filters' => array('q' => $q, 'state' => $state), + 'active_tab' => $activeTab, 'can_manage' => Permission::check('manage_rubrics'), 'can_view_documents' => Permission::check('view_documents'), 'can_manage_documents' => Permission::check('manage_documents'), )); } + public function fieldSets(array $params = array()) + { + if (!Permission::check('view_rubrics')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + AdminAssets::addStyle($this->base() . '/modules/Rubrics/assets/field-sets.css', 55); + AdminAssets::addScript($this->base() . '/modules/Rubrics/assets/field-sets.js', 55); + $sets = RubricFieldPresets::all(); + $fields = 0; + foreach ($sets as $set) { $fields += (int) $set['field_count']; } + + return $this->render('@rubrics/field-sets.twig', array( + 'field_sets' => $sets, + 'rubrics' => Model::all(), + 'stats' => array( + 'total' => count($sets), + 'fields' => $fields, + 'available' => count(array_filter($sets, function ($set) { return !empty($set['available']); })), + ), + 'can_manage' => Permission::check('manage_rubrics'), + )); + } + public function templateTags(array $params = array()) { if (!Permission::check('view_rubrics')) { @@ -457,6 +481,7 @@ } $preset = Request::postStr('rubric_preset', ''); + $isDirectory = Request::postStr('rubric_purpose', '') === 'directory'; $presetError = RubricFieldPresets::availabilityError($preset); if ($presetError !== '') { return $this->error('Стартовый набор недоступен', array('rubric_preset' => $presetError), 422); @@ -466,17 +491,23 @@ try { $id = Model::saveRubric(0, Request::postAll(), Auth::id()); $created = RubricFieldPresets::apply($preset, $id, Auth::id()); - $this->captureSchemaAfter($id, 'create', 'Создание рубрики и начальной схемы'); + $this->captureSchemaAfter( + $id, + 'create', + $isDirectory ? 'Создание справочника и начальной схемы' : 'Создание рубрики и начальной схемы' + ); DB::commit(); } catch (\Throwable $e) { DB::rollback(); return $this->error($e->getMessage(), array(), 422); } - $message = $created['fields'] > 0 ? 'Рубрика и стартовый набор полей созданы' : 'Рубрика создана'; + $message = $isDirectory + ? ($created['fields'] > 0 ? 'Справочник и стартовый набор полей созданы' : 'Справочник создан') + : ($created['fields'] > 0 ? 'Рубрика и стартовый набор полей созданы' : 'Рубрика создана'); return $this->success($message, array( 'data' => array('id' => $id, 'preset' => $preset, 'created' => $created), - 'redirect' => $this->base() . '/rubrics', + 'redirect' => $this->base() . ($isDirectory ? '/directories' : '/rubrics'), )); } @@ -496,8 +527,12 @@ return $this->error('Проверьте поля рубрики', $errors, 422); } + $isDirectory = Request::postStr('rubric_purpose', '') === 'directory'; Model::saveRubric($id, Request::postAll(), Auth::id()); - return $this->success('Рубрика сохранена', array('redirect' => $this->base() . '/rubrics')); + return $this->success( + $isDirectory ? 'Справочник сохранён' : 'Рубрика сохранена', + array('redirect' => $this->base() . ($isDirectory ? '/directories' : '/rubrics')) + ); } public function destroyRubric(array $params = array()) diff --git a/adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php b/adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php index 24ff753..8b01a8f 100644 --- a/adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php +++ b/adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php @@ -536,7 +536,7 @@ 'image_mega' => array( 'title' => 'Расширенная галерея', 'icon' => 'photo-cog', - 'summary' => 'Сложное поле изображений. Adminx-редактор будет развиваться поверх медиабраузера.', + 'summary' => 'Сложное поле изображений. Редактор панели управления работает поверх медиабраузера.', 'default_label' => 'Настройки по умолчанию', 'default_kind' => 'media_list', 'entity_fields' => array('items[].url', 'items[].title', 'items[].description', 'items[].link'), diff --git a/adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php b/adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php index 7bbee64..ec09f34 100644 --- a/adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php +++ b/adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php @@ -244,6 +244,15 @@ $kind = $mode === 'code' ? 'code' : ($mode === 'plain' ? 'textarea' : 'rich'); } + if ($type === 'computed') { + $value = is_array($parsed) ? '' : (string) $parsed; + $name = $this->e($this->fname($field, '[raw]')); + return '' + . '' + . 'Значение пересчитывается при сохранении документа.'; + } + if ($type === 'date_time') { $mode = isset($settings['mode']) ? (string) $settings['mode'] : 'date'; $timestamp = isset($field['field_value']) ? (int) $field['field_value'] : 0; @@ -615,6 +624,15 @@ 'default_kind' => 'code', 'entity_fields' => array('value'), ), + 'computed' => array( + 'title' => 'Вычисляемое поле', + 'icon' => 'math-function', + 'summary' => 'Значение автоматически рассчитывается по формуле из других полей документа.', + 'default_label' => 'Резервное значение', + 'default_hint' => 'Используется только когда формулу невозможно вычислить.', + 'default_kind' => 'text', + 'entity_fields' => array('value'), + ), ); } diff --git a/adminx/modules/Rubrics/Model.php b/adminx/modules/Rubrics/Model.php index b82522f..d66cfd2 100644 --- a/adminx/modules/Rubrics/Model.php +++ b/adminx/modules/Rubrics/Model.php @@ -23,6 +23,7 @@ use App\Common\AuditLog; use App\Common\FileCacheInvalidator; use App\Content\Fields\FieldConditionEvaluator; + use App\Content\Fields\FieldRegistry; use App\Content\Fields\FieldSettings; use App\Content\Fields\FieldSettingsForm; use App\Helpers\Json; @@ -185,7 +186,7 @@ $fields = DB::query('SELECT Id FROM ' . self::fieldsTable() . ' WHERE rubric_id = %i', $id)->getAll() ?: array(); foreach ($fields as $field) { - $fieldDependencies = self::fieldDependencies((int) $field['Id']); + $fieldDependencies = self::fieldDependencies((int) $field['Id'], false); if (!empty($fieldDependencies)) { throw new \RuntimeException( 'Поле #' . (int) $field['Id'] . ' используется: ' . implode(', ', $fieldDependencies) @@ -527,6 +528,7 @@ DB::Delete(self::docFieldsTextTable(), 'rubric_field_id = %i', (int) $id); } + \App\Content\Fields\DocumentRelationIndex::removeField((int) $id); self::deleteDerivedFieldData((int) $id); self::touchFields((int) $field['rubric_id']); return true; @@ -766,7 +768,7 @@ ? (string) $draft['description'] : (string) $field['rubric_field_description']; $data['rubric_field_search'] = !empty($draft['search']) ? '1' : '0'; - $nativeNumeric = in_array((string) $field['rubric_field_type'], array('number', 'date_time', 'period'), true); + $nativeNumeric = self::fieldTypeIsNumeric((string) $field['rubric_field_type']); $data['rubric_field_numeric'] = $nativeNumeric || !empty($draft['numeric']) ? '1' : '0'; } @@ -929,45 +931,17 @@ public static function documentPicker($q = '', $rubricId = 0, $limit = 20) { - $limit = max(5, min(50, (int) $limit)); - $sql = 'SELECT d.Id, d.rubric_id, d.document_title, d.document_alias, d.document_status, d.document_deleted, r.rubric_title' - . ' FROM ' . self::docsTable() . ' d' - . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' - . ' WHERE d.document_deleted != 1'; - $args = array(); - if ((int) $rubricId > 0) { - $sql .= ' AND d.rubric_id = %i'; - $args[] = (int) $rubricId; - } - - $q = trim((string) $q); - if ($q !== '') { - $sql .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id = %i)'; - $args[] = $q; - $args[] = $q; - $args[] = (int) $q; - } - - $sql .= ' ORDER BY d.Id DESC LIMIT ' . (int) $limit; - $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); - $out = array(); - foreach ($rows as $row) { - $out[] = array( - 'id' => (int) $row['Id'], - 'rubric_id' => (int) $row['rubric_id'], - 'title' => (string) $row['document_title'], - 'alias' => (string) $row['document_alias'], - 'status' => (int) $row['document_status'], - 'rubric_title' => isset($row['rubric_title']) ? (string) $row['rubric_title'] : '', - ); - } - - return $out; + return (new \App\Content\Documents\DocumentPickerRepository())->search($q, $rubricId, $limit); } protected static function rubricInput(array $input) { - return array( + $purpose = isset($input['rubric_purpose']) ? trim((string) $input['rubric_purpose']) : 'content'; + if (!in_array($purpose, array('content', 'directory'), true)) { + $purpose = 'content'; + } + + $data = array( 'rubric_title' => trim(isset($input['rubric_title']) ? (string) $input['rubric_title'] : ''), 'rubric_alias' => trim(isset($input['rubric_alias']) ? (string) $input['rubric_alias'] : ''), 'rubric_template_id' => max(1, (int) (isset($input['rubric_template_id']) ? $input['rubric_template_id'] : 1)), @@ -979,6 +953,11 @@ 'rubric_code_end' => isset($input['rubric_code_end']) ? (string) $input['rubric_code_end'] : '', 'rubric_start_code' => isset($input['rubric_start_code']) ? (string) $input['rubric_start_code'] : '', ); + if (self::hasRubricPurposeColumn()) { + $data['rubric_purpose'] = $purpose; + } + + return $data; } protected static function fieldInput(array $input) @@ -990,7 +969,7 @@ 'rubric_field_title' => trim(isset($input['rubric_field_title']) ? (string) $input['rubric_field_title'] : ''), 'rubric_field_alias' => trim(isset($input['rubric_field_alias']) ? (string) $input['rubric_field_alias'] : ''), 'rubric_field_type' => $type, - 'rubric_field_numeric' => in_array($type, array('number', 'date_time', 'period'), true) || !empty($input['rubric_field_numeric']) ? '1' : '0', + 'rubric_field_numeric' => self::fieldTypeIsNumeric($type) || !empty($input['rubric_field_numeric']) ? '1' : '0', 'rubric_field_search' => !empty($input['rubric_field_search']) ? '1' : '0', 'rubric_field_default' => $normalizedDefault, 'rubric_field_template' => isset($input['rubric_field_template']) ? (string) $input['rubric_field_template'] : '', @@ -1015,6 +994,12 @@ return $data; } + protected static function fieldTypeIsNumeric($type) + { + $fieldType = FieldRegistry::get((string) $type); + return $fieldType ? $fieldType->isNumeric() : false; + } + protected static function normalizeFieldDefault($type, $value) { return FieldAdminEditors::normalizeDefault($type, $value); @@ -1088,8 +1073,15 @@ return self::fieldHasValues((int) $fieldId); } - protected static function fieldDependencies($fieldId) + protected static function fieldDependencies($fieldId, $includeLocalSchema = true) { + $field = DB::query( + 'SELECT Id,rubric_id,rubric_field_alias,rubric_field_title FROM ' . self::fieldsTable() + . ' WHERE Id=%i LIMIT 1', + (int) $fieldId + )->getAssoc(); + if (!$field) { return array(); } + $checks = array( array(ContentTables::table('request_conditions'), 'condition_field_id', 'условия запросов'), array(CatalogTables::table('module_catalog_settings'), 'field_id', 'настройки каталога'), @@ -1138,9 +1130,120 @@ $out[] = 'параметры товарных фидов'; } + $out = array_merge($out, self::requestFieldDependencies($field)); + if ($includeLocalSchema) { + $out = array_merge($out, self::localFieldDependencies($field)); + } + return array_values(array_unique($out)); } + protected static function requestFieldDependencies(array $field) + { + $table = ContentTables::table('request'); + if (!self::tableExists($table)) { return array(); } + $rows = DB::query( + 'SELECT Id,request_order_by_nat,request_sort_rules,request_template_item,request_template_main,request_result_contract' + . ' FROM ' . $table . ' WHERE rubric_id=%i', + (int) $field['rubric_id'] + )->getAll() ?: array(); + $reference = self::fieldReference($field); + $out = array(); + foreach ($rows as $request) { + foreach (\App\Frontend\RequestSort::rulesForRequest($request) as $rule) { + if ((string) $rule['source'] === 'field' && (int) $rule['key'] === (int) $field['Id']) { + $out[] = 'сортировка запросов'; + break; + } + } + + if (RubricSchemaImpact::templateReferences( + (string) $request['request_template_item'] . "\n" . (string) $request['request_template_main'], + array($reference) + )) { + $out[] = 'шаблоны запросов'; + } + + $contract = \App\Content\Requests\RequestResultContract::decode((string) $request['request_result_contract']); + if (in_array((int) $field['Id'], array_map('intval', $contract['fields']), true)) { + $out[] = 'контракты результатов запросов'; + } + } + + return array_values(array_unique($out)); + } + + protected static function localFieldDependencies(array $field) + { + $rubricId = (int) $field['rubric_id']; + $fieldId = (int) $field['Id']; + $alias = strtolower(trim((string) $field['rubric_field_alias'])); + $out = array(); + foreach (self::fieldsForRubric($rubricId) as $candidate) { + if ((int) $candidate['Id'] !== $fieldId + && self::conditionUsesField(FieldConditionEvaluator::condition($candidate), $fieldId, $alias)) { + $out[] = 'условия видимости полей'; + } + + if (self::conditionUsesField(FieldConditionEvaluator::groupCondition($candidate), $fieldId, $alias)) { + $out[] = 'условия видимости групп'; + } + } + + $reference = self::fieldReference($field); + $rubric = DB::query( + 'SELECT rubric_template,rubric_teaser_template,rubric_header_template,rubric_og_template,rubric_footer_template' + . ' FROM ' . self::rubricsTable() . ' WHERE Id=%i LIMIT 1', + $rubricId + )->getAssoc(); + if ($rubric && RubricSchemaImpact::templateReferences(implode("\n", $rubric), array($reference))) { + $out[] = 'шаблоны рубрики'; + } + + if (self::tableExists(self::templatesTable())) { + foreach (DB::query( + 'SELECT template FROM ' . self::templatesTable() . ' WHERE rubric_id=%i', + $rubricId + )->getAll() ?: array() as $template) { + if (RubricSchemaImpact::templateReferences((string) $template['template'], array($reference))) { + $out[] = 'дополнительные шаблоны рубрики'; + break; + } + } + } + + return array_values(array_unique($out)); + } + + protected static function conditionUsesField(array $condition, $fieldId, $alias) + { + $walk = function (array $node) use (&$walk, $fieldId, $alias) { + if (isset($node['items']) && is_array($node['items'])) { + foreach ($node['items'] as $item) { + if (is_array($item) && $walk($item)) { return true; } + } + + return false; + } + + $reference = strtolower(trim(isset($node['field']) ? (string) $node['field'] : '')); + return $reference === 'id:' . (int) $fieldId + || $reference === (string) (int) $fieldId + || ($alias !== '' && ($reference === 'alias:' . $alias || $reference === $alias)); + }; + + return !empty($condition['tree']) && $walk($condition['tree']); + } + + protected static function fieldReference(array $field) + { + return array( + 'id' => (int) $field['Id'], + 'title' => isset($field['rubric_field_title']) ? (string) $field['rubric_field_title'] : '', + 'alias' => isset($field['rubric_field_alias']) ? (string) $field['rubric_field_alias'] : '', + ); + } + protected static function rubricDependencies($rubricId) { $checks = array( @@ -1255,6 +1358,10 @@ $row['templates_count'] = isset($row['templates_count']) ? (int) $row['templates_count'] : 0; $row['site_template_title'] = isset($row['site_template_title']) ? trim((string) $row['site_template_title']) : ''; $row['form_conditions_enabled'] = !empty($row['rubric_form_conditions']); + $row['rubric_purpose'] = isset($row['rubric_purpose']) && (string) $row['rubric_purpose'] === 'directory' + ? 'directory' + : 'content'; + $row['purpose_label'] = $row['rubric_purpose'] === 'directory' ? 'справочник' : 'контент'; $row['can_delete'] = $row['Id'] > 1 && $row['docs_count'] === 0; $row['status_label'] = (string) $row['rubric_docs_active'] === '1' ? 'активна' : 'выкл'; return $row; @@ -1437,6 +1544,16 @@ return $has; } + public static function hasRubricPurposeColumn() + { + static $has = null; + if ($has === null) { + $has = (bool) DB::query('SHOW COLUMNS FROM ' . self::rubricsTable() . ' LIKE %s', 'rubric_purpose')->getValue(); + } + + return $has; + } + protected static function hasGroupSettingsColumn() { static $has = null; diff --git a/adminx/modules/Rubrics/assets/field-sets.css b/adminx/modules/Rubrics/assets/field-sets.css new file mode 100644 index 0000000..90f5c2f --- /dev/null +++ b/adminx/modules/Rubrics/assets/field-sets.css @@ -0,0 +1,108 @@ +.content-model-tabs { + margin-bottom: var(--space-5); +} +.field-sets-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-5); +} +.field-sets-stat { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-4); +} +.field-sets-stat b { + display: block; + font-size: 20px; + line-height: 1.1; + font-weight: 800; +} +.field-sets-stat span { + color: var(--text-secondary); +} +.field-sets-panel-header { + margin-bottom: var(--space-4); +} +.field-sets-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); +} +.field-set-card { + display: grid; + align-content: start; + gap: var(--space-4); + padding: var(--space-4); +} +.field-set-card.is-unavailable { + opacity: 0.65; +} +.field-set-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: var(--space-3); +} +.field-set-head h3, +.field-set-head p { + margin: 0; +} +.field-set-head h3 { + font-size: 16px; +} +.field-set-head p { + margin-top: 3px; + color: var(--text-secondary); + font-size: 13px; +} +.field-set-groups { + display: grid; + gap: var(--space-2); +} +.field-set-groups > div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + background: var(--surface-muted); +} +.field-set-groups small { + grid-column: 1 / -1; + color: var(--text-secondary); + line-height: 1.45; +} +.field-set-actions { + display: grid; + grid-template-columns: minmax(180px, 1fr) auto auto; + align-items: end; + gap: var(--space-3); +} +.field-set-warning { + display: flex; + align-items: center; + gap: var(--space-2); + margin: 0; + color: var(--amber-700); + font-size: 12px; +} +@media (max-width: 980px) { + .field-sets-grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 720px) { + .field-sets-summary, + .field-set-actions { + grid-template-columns: 1fr; + } + .field-set-head { + grid-template-columns: auto minmax(0, 1fr); + } + .field-set-head > .badge { + grid-column: 2; + justify-self: start; + } +} diff --git a/adminx/modules/Rubrics/assets/field-sets.js b/adminx/modules/Rubrics/assets/field-sets.js new file mode 100644 index 0000000..855782a --- /dev/null +++ b/adminx/modules/Rubrics/assets/field-sets.js @@ -0,0 +1,52 @@ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx = window.Adminx || {}; + + function init() { + var root = document.querySelector('[data-field-sets-root]'); + if (!root) { return; } + var base = root.getAttribute('data-base') || Adminx.base(); + + root.addEventListener('click', function (event) { + var mode = event.target.closest('[data-field-set-mode]'); + if (mode) { + var segmented = mode.closest('.segmented'); + segmented.querySelectorAll('[data-field-set-mode]').forEach(function (button) { + button.setAttribute('aria-pressed', button === mode ? 'true' : 'false'); + }); + return; + } + + var apply = event.target.closest('[data-field-set-apply]'); + if (!apply) { return; } + var card = apply.closest('[data-field-set]'); + var rubric = card.querySelector('[data-field-set-rubric]').value; + var selectedMode = card.querySelector('[data-field-set-mode][aria-pressed="true"]'); + if (!rubric) { + Adminx.Toast.show('Выберите рубрику', 'warning'); + return; + } + + apply.disabled = true; + var data = new FormData(); + data.append('_csrf', Adminx.csrf()); + data.append('mode', selectedMode ? selectedMode.getAttribute('data-field-set-mode') : 'copy'); + fetch(base + '/rubrics/' + rubric + '/field-sets/' + encodeURIComponent(card.getAttribute('data-field-set')) + '/apply', { + method: 'POST', + body: data, + credentials: 'same-origin', + headers: { 'Accept': 'application/json' } + }) + .then(function (response) { return response.json(); }) + .then(function (json) { + if (!json.ok) { throw new Error(json.message || 'Не удалось добавить набор'); } + Adminx.Toast.show(json.message || 'Набор добавлен', 'success'); + }) + .catch(function (error) { Adminx.Toast.show(error.message, 'error'); }) + .finally(function () { apply.disabled = false; }); + }); + } + + document.addEventListener('DOMContentLoaded', init); +})(window, document); diff --git a/adminx/modules/Rubrics/assets/rubrics.css b/adminx/modules/Rubrics/assets/rubrics.css index dd443cc..4c12928 100644 --- a/adminx/modules/Rubrics/assets/rubrics.css +++ b/adminx/modules/Rubrics/assets/rubrics.css @@ -21,7 +21,6 @@ font-size: 20px; line-height: 1.1; font-weight: 800; - font-variant-numeric: tabular-nums; } .rubrics-stat span { font-size: 13px; @@ -42,7 +41,8 @@ .rubrics-panel-header { margin-bottom: 18px; } -.rubrics-main-tabs { +.rubrics-main-tabs, +.content-model-tabs { margin-bottom: 16px; } .rubrics-panel-header h2 { @@ -196,7 +196,8 @@ display: grid; grid-template-columns: minmax(260px, 1fr) minmax(180px, 240px); gap: 12px; - padding: 0 16px 14px; + padding: 14px 20px; + border-bottom: 1px solid var(--border-default); } .rubrics-type-tools .input-wrap, .rubrics-type-tools .select { @@ -311,7 +312,6 @@ .rubrics-materials-link b, .rubrics-materials-count b { font-size: 12px; - font-variant-numeric: tabular-nums; } .rubrics-materials-link small, .rubrics-materials-count small, @@ -675,7 +675,6 @@ .rubrics-revision-metric b { font-size: 18px; line-height: 1; - font-variant-numeric: tabular-nums; } .rubrics-revision-metric span { margin-top: 3px; @@ -746,7 +745,6 @@ color: var(--text-secondary); font-size: 11px; font-weight: 800; - font-variant-numeric: tabular-nums; box-shadow: inset 0 0 0 1px var(--border-subtle); } .rubrics-revision-dependency-summary { @@ -773,7 +771,6 @@ } .rubrics-revision-dependency-summary b { color: var(--text-primary); - font-variant-numeric: tabular-nums; } .rubrics-revision-dependency-group { margin-top: 8px; @@ -812,7 +809,6 @@ color: var(--text-secondary); font-size: 10.5px; text-align: right; - font-variant-numeric: tabular-nums; } .rubrics-revision-dependency-group[open] > summary { box-shadow: inset 0 -1px 0 var(--border-subtle); @@ -1500,7 +1496,6 @@ color: var(--text-secondary); font-size: 12px; font-weight: 700; - font-variant-numeric: tabular-nums; box-shadow: inset 0 0 0 1px var(--border-subtle); } .rubrics-default-list-row .input { @@ -1679,7 +1674,6 @@ color: var(--text-secondary); font-size: 12px; font-weight: 800; - font-variant-numeric: tabular-nums; } .rubrics-relation-main { display: grid; @@ -2112,7 +2106,6 @@ } .rubrics-field-set-preview-grid b { font-size: 20px; - font-variant-numeric: tabular-nums; } .rubrics-field-set-preview-grid small { color: var(--text-secondary); @@ -2377,7 +2370,6 @@ color: var(--text-tertiary); font-size: 10.5px; font-weight: 700; - font-variant-numeric: tabular-nums; } .rubrics-builder-field-condition { display: grid; @@ -2592,7 +2584,6 @@ color: var(--text-secondary); font-size: 10.5px; font-weight: 700; - font-variant-numeric: tabular-nums; cursor: pointer; box-shadow: inset 0 0 0 1px var(--border-subtle); transition-property: background-color, color, box-shadow, transform; @@ -3179,7 +3170,6 @@ } .rubrics-impact-summary b { font-size: 20px; - font-variant-numeric: tabular-nums; } .rubrics-impact-summary small { margin-top: 2px; @@ -3559,7 +3549,6 @@ .rubrics-template-tag-nav b { color: var(--text-muted); font-size: 10.5px; - font-variant-numeric: tabular-nums; } .rubrics-template-tag-nav button.is-active b { color: var(--color-primary); @@ -3654,7 +3643,6 @@ gap: 3px; color: var(--text-secondary); font-size: 12px; - font-variant-numeric: tabular-nums; } .rubrics-fields-list, .rubrics-fields-side { @@ -4317,7 +4305,6 @@ .rubrics-admin-view-item-order { color: var(--text-muted); font: 12px var(--font-mono); - font-variant-numeric: tabular-nums; } .rubrics-admin-view-item-main { min-width: 0; diff --git a/adminx/modules/Rubrics/assets/rubrics.js b/adminx/modules/Rubrics/assets/rubrics.js index a211fc9..fee3497 100644 --- a/adminx/modules/Rubrics/assets/rubrics.js +++ b/adminx/modules/Rubrics/assets/rubrics.js @@ -49,6 +49,7 @@ currentSchemaRevisionRubricId: 0, currentSchemaRevisionId: 0, currentSchemaRevisionFingerprint: '', + pendingExtraTemplateId: 0, init: function () { this.form = document.getElementById('rubricForm'); @@ -60,12 +61,40 @@ this.adminViewForm = document.getElementById('rubricAdminViewForm'); this.syncFieldTypeOptions(''); this.bind(); + this.openFromLocation(); }, base: function () { return (this.form && this.form.getAttribute('data-base')) || Adminx.base(); }, + openFromLocation: function () { + var params = new URLSearchParams(window.location.search); + var rubricId = parseInt(params.get('templates'), 10) || 0; + var editId = parseInt(params.get('edit'), 10) || 0; + var create = params.get('create') === '1'; + var row; + if (rubricId) { + row = document.querySelector('[data-rubric-row][data-id="' + rubricId + '"]'); + if (!row) { return; } + this.pendingExtraTemplateId = parseInt(params.get('template'), 10) || 0; + if (Adminx.Drawer) { Adminx.Drawer.open('rubricTemplatesDrawer'); } + this.openTemplates(row); + return; + } + if (editId) { + row = document.querySelector('[data-rubric-row][data-id="' + editId + '"]'); + if (!row) { return; } + if (Adminx.Drawer) { Adminx.Drawer.open('rubricDrawer'); } + this.fillRubricEdit(row); + return; + } + if (create) { + if (Adminx.Drawer) { Adminx.Drawer.open('rubricDrawer'); } + this.fillRubricNew(params.get('purpose')); + } + }, + bind: function () { var self = this; @@ -75,7 +104,7 @@ var mainTab = e.target.closest('[data-rubrics-main-tab]'); if (mainTab) { self.activateMainTab(mainTab.getAttribute('data-rubrics-main-tab')); } if (e.target.closest('[data-rubrics-filter-reset]')) { self.resetFilters(); } - if (e.target.closest('[data-rubric-new]')) { self.fillRubricNew(); } + if (e.target.closest('[data-rubric-new]')) { self.fillRubricNew('content'); } var edit = e.target.closest('[data-rubric-edit]'); if (edit) { self.fillRubricEdit(edit.closest('[data-rubric-row]')); } var del = e.target.closest('[data-rubric-delete]'); @@ -280,6 +309,9 @@ if (e.target.matches('[data-settings-map-key], [data-settings-map-label]')) { self.syncSettingsMap(e.target.closest('[data-settings-map]')); } + if (e.target.matches('[name="rubric_field_settings[option_source]"]')) { + self.syncOptionSourceFields(e.target.closest('[data-field-settings]')); + } if (e.target.matches('[data-builder-conditions-enabled]')) { self.syncBuilderRuntime(); self.setBuilderDirty(true); @@ -594,11 +626,15 @@ .finally(function () { Adminx.Loader.hide(); }); }, - fillRubricNew: function () { + fillRubricNew: function (purpose) { this.clearErrors(this.form); this.form.reset(); this.form.elements.id.value = ''; this.form.elements.rubric_template_id.value = '1'; + var isDirectory = purpose === 'directory'; + if (this.form.elements.rubric_purpose) { + this.form.elements.rubric_purpose.value = isDirectory ? 'directory' : 'content'; + } var presetWrap = this.form.querySelector('[data-rubric-preset-wrap]'); if (presetWrap) { presetWrap.hidden = false; } if (this.form.elements.rubric_preset) { this.form.elements.rubric_preset.value = ''; } @@ -607,7 +643,15 @@ this.setCodeValue(this.form.elements.rubric_code_end, ''); this.setCodeValue(this.form.elements.rubric_start_code, ''); if (this.form.elements.rubric_template_id.selectedIndex < 0) { this.form.elements.rubric_template_id.selectedIndex = 0; } - document.getElementById('rubricDrawerTitle').textContent = 'Новая рубрика'; + document.getElementById('rubricDrawerTitle').textContent = isDirectory ? 'Новый справочник' : 'Новая рубрика'; + var subtitle = this.form.closest('.drawer').querySelector('[data-rubric-drawer-subtitle]'); + if (subtitle) { + subtitle.textContent = isDirectory + ? 'Настройте структуру записей справочника.' + : 'Базовые параметры рубрики.'; + } + var submitLabel = this.form.querySelector('[data-rubric-submit-label]'); + if (submitLabel) { submitLabel.textContent = isDirectory ? 'Создать справочник' : 'Сохранить'; } this.setHint('[data-rubric-alias-state]', ''); this.updateRubricAliasPreview(); this.refreshEditors(); @@ -624,6 +668,9 @@ self.form.elements.id.value = item.Id || ''; self.form.elements.rubric_title.value = item.rubric_title || ''; self.form.elements.rubric_alias.value = item.rubric_alias || ''; + if (self.form.elements.rubric_purpose) { + self.form.elements.rubric_purpose.value = item.rubric_purpose === 'directory' ? 'directory' : 'content'; + } var presetWrap = self.form.querySelector('[data-rubric-preset-wrap]'); if (presetWrap) { presetWrap.hidden = true; } self.form.elements.rubric_template_id.value = item.rubric_template_id || 1; @@ -635,7 +682,17 @@ self.setCodeValue(self.form.elements.rubric_code_start, item.rubric_code_start || ''); self.setCodeValue(self.form.elements.rubric_code_end, item.rubric_code_end || ''); self.setCodeValue(self.form.elements.rubric_start_code, item.rubric_start_code || ''); - document.getElementById('rubricDrawerTitle').textContent = 'Редактирование рубрики #' + item.Id; + document.getElementById('rubricDrawerTitle').textContent = (item.rubric_purpose === 'directory' + ? 'Редактирование справочника #' + : 'Редактирование рубрики #') + item.Id; + var subtitle = self.form.closest('.drawer').querySelector('[data-rubric-drawer-subtitle]'); + if (subtitle) { + subtitle.textContent = item.rubric_purpose === 'directory' + ? 'Настройка структуры записей справочника.' + : 'Базовые параметры рубрики.'; + } + var submitLabel = self.form.querySelector('[data-rubric-submit-label]'); + if (submitLabel) { submitLabel.textContent = 'Сохранить'; } self.setHint('[data-rubric-alias-state]', ''); self.updateRubricAliasPreview(); self.refreshEditors(); @@ -645,6 +702,8 @@ submitRubric: function () { var self = this; var id = this.form.elements.id.value; + var isDirectory = this.form.elements.rubric_purpose + && this.form.elements.rubric_purpose.value === 'directory'; this.saveEditors(); this.clearErrors(this.form); this.checkRubricAlias(true, function (ok) { @@ -652,6 +711,10 @@ self.ajax(self.base() + '/rubrics' + (id ? '/' + id : ''), new FormData(self.form), function (json) { Adminx.Toast.show(json.message || 'Рубрика сохранена', 'success'); if (Adminx.Drawer) { Adminx.Drawer.close(); } + if (isDirectory) { + window.location.href = self.base() + '/directories'; + return; + } self.applyFilterUrl(window.location.href, false); }, function (json) { self.showErrors(self.form, json); }); }); @@ -741,7 +804,7 @@ self.adminViewForm.elements.template.value = self.adminViewState.template; document.getElementById('rubricAdminViewTitle').textContent = 'Вид документов: ' + (rubric.rubric_title || ''); var subtitle = document.querySelector('[data-admin-view-subtitle]'); - if (subtitle) { subtitle.textContent = 'Рубрика #' + self.currentRubricId + ' · настройка действует только в Adminx.'; } + if (subtitle) { subtitle.textContent = 'Рубрика #' + self.currentRubricId + ' · настройка действует только в панели управления.'; } var documentsLink = document.querySelector('[data-admin-view-documents]'); if (documentsLink) { documentsLink.href = self.base() + '/documents?rubric_id=' + self.currentRubricId; } self.renderAdminView(); @@ -1069,6 +1132,14 @@ self.renderTemplateTags(data.fields || []); self.renderExtraTemplates(data.templates || []); self.refreshEditors(); + if (self.pendingExtraTemplateId) { + var row = document.querySelector('[data-extra-template-row][data-id="' + self.pendingExtraTemplateId + '"]'); + self.pendingExtraTemplateId = 0; + if (row) { + if (Adminx.Drawer) { Adminx.Drawer.open('rubricExtraTemplateDrawer'); } + self.fillExtraTemplateEdit(row); + } + } }) .catch(function () { Adminx.Toast.show('Не удалось загрузить шаблоны рубрики', 'error'); }); }, @@ -2087,6 +2158,7 @@ : '

У этого типа нет дополнительных настроек.

'; if (draftSettings !== null) { self.applyBuilderSettingsDraft(root, draftSettings); } self.hydrateSettingsMaps(root); + self.syncOptionSourceFields(root); self.toggleBuilderDefaultField(type.admin_editor || {}); self.builderSettingsReady = true; }) @@ -2489,6 +2561,17 @@ }); }, + syncOptionSourceFields: function (root) { + root = root || document; + var source = root.querySelector('[name="rubric_field_settings[option_source]"]'); + if (!source) { return; } + var shared = source.value === 'directory'; + var directory = root.querySelector('[data-field-setting-key="directory_id"]'); + var options = root.querySelector('[data-field-setting-key="options"]'); + if (directory) { directory.hidden = !shared; } + if (options) { options.hidden = shared; } + }, + addSettingsMapRow: function (map) { var rows = map ? map.querySelector('[data-settings-map-rows]') : null; if (!rows) { return; } @@ -2917,6 +3000,8 @@ + '
' + (settings || '

У этого типа нет дополнительных настроек.

') + issues; + this.hydrateSettingsMaps(root); + this.syncOptionSourceFields(root); }, renderAdminEditor: function (editor) { diff --git a/adminx/modules/Rubrics/language/en/interface.xml b/adminx/modules/Rubrics/language/en/interface.xml index f26460b..c54735e 100644 --- a/adminx/modules/Rubrics/language/en/interface.xml +++ b/adminx/modules/Rubrics/language/en/interface.xml @@ -19,7 +19,6 @@ HTML templates for displaying fields and requests. ID is already available. Changed document properties are automatically written again without re-running hooks; field values ​​are saved after the handler. ID, title, alias or description - Legacy fields cannot be deleted yet OG markup PHP and JavaScript are prohibited. Document and added field tags are available. {{ type.enabled ? 'Disable' : 'Enable' }} type {{ type.name }} @@ -35,13 +34,11 @@ field alias Announcement Announcements - archive files and is not included in a clean installation. Basic markup Basic parameters of the category. without alias Without a group Secure HTML Markup - The database uses in the current filter, Template option for category documents. Type of documents @@ -126,14 +123,13 @@ Context and system APIs Public Output Context Copy - The root legacy folder has been deleted; public and Adminx use only native handlers. - The root folder contains Materials Meta tags for social networks and instant messengers on the pages of this section. Label thumbnail: c-crop/r-resize/f-fit You can hide a group or prevent changes to all its fields. Field set + Field sets field name Site name Title, URL alias and description of the category. @@ -146,12 +142,10 @@ Settings depending on the selected field type. Set up a workspace for new rubrics. Disabling does not affect already created fields. Set up the composition of the form, then immediately go to the materials of the selected category. - The native registry covers the working base Not selected Do not change Don't index Not saved - No handlers: New group New section New meaning @@ -164,7 +158,7 @@ Required when showing Mandatoryness is configured in the “Type Editor” → Validation. Limit options - Description, use, and availability of each type when creating a field. + Choose which fields are available when configuring rubrics. Main Main conclusion Basic template @@ -183,7 +177,7 @@ The first documents in the category, without saving settings. Drag fields by the handle. Drag the rows by the handle to change the order. - Field plugins + Available field types No types were found for the selected conditions. Signature Search tags @@ -198,6 +192,7 @@ Half Fields in a compact grid Category fields + Rubric fields Once added, the field disappears from this list. After saving the document Consequences of recovery @@ -210,7 +205,6 @@ Also checked on the server continue to work. View the structure of categories. - Direct connections: Publishing and output Public teaser template for site lists and requests. Empty category @@ -225,6 +219,8 @@ Rubrik The category will be created without additional fields. Categories + Directories + Content structure No categories found. Your own template Properties of the selected field. @@ -258,8 +254,7 @@ Table Current document: Field type - The field type is stored in the category format, the admin editor builds adminx. - types; registered + The type is stored in the rubric schema; the control panel selects the appropriate editor. Types of materials, their fields and design. Field types No field types found. diff --git a/adminx/modules/Rubrics/language/en/runtime.xml b/adminx/modules/Rubrics/language/en/runtime.xml index d428adb..79eb03e 100644 --- a/adminx/modules/Rubrics/language/en/runtime.xml +++ b/adminx/modules/Rubrics/language/en/runtime.xml @@ -396,7 +396,7 @@ Synchronized set " System name Hide - Complex field of images. The Adminx editor will be developed on top of the media browser. + Complex image field. The control panel editor works on top of the media browser. Preview the set first First apply rubric migrations to group conditions Check the set changes first diff --git a/adminx/modules/Rubrics/language/ru/interface.xml b/adminx/modules/Rubrics/language/ru/interface.xml index b75fb96..4a79571 100644 --- a/adminx/modules/Rubrics/language/ru/interface.xml +++ b/adminx/modules/Rubrics/language/ru/interface.xml @@ -19,7 +19,6 @@ HTML-шаблоны отображения поля и request. ID уже доступен. Изменённые свойства документа автоматически записываются ещё раз без повторного запуска хуков; значения полей сохраняются после обработчика. ID, название, алиас или описание - Legacy fields пока нельзя удалять OG-разметка PHP и JavaScript запрещены. Доступны теги документа и добавленных полей. {{ type.enabled ? 'Отключить' : 'Включить' }} тип {{ type.name }} @@ -35,13 +34,11 @@ алиас поля Анонс Анонсы - архивных файлов и не входит в чистую установку. Базовая разметка Базовые параметры рубрики. без алиаса Без группы Безопасная HTML-разметка - В БД используется в текущем фильтре, Вариант шаблона для документов рубрики. Вид документов @@ -126,14 +123,13 @@ Контекст и системные API Контекст публичного вывода Копия - Корневая legacy-папка удалена; public и Adminx используют только нативные обработчики. - Корневая папка содержит Материалы Метатеги для соцсетей и мессенджеров на страницах этой рубрики. Метка миниатюра: c-обрезка/r-ресайз/f-вписать Можно скрыть группу или запретить изменение всех её полей. Набор полей + Наборы полей название поля Название сайта Название, URL-алиас и описание рубрики. @@ -146,12 +142,10 @@ Настройки, зависящие от выбранного типа поля. Настройте рабочий набор для новых рубрик. Отключение не влияет на уже созданные поля. Настройте состав формы, затем сразу переходите к материалам выбранной рубрики. - Нативный реестр покрывает рабочую базу Не выбран Не изменять Не индексировать Не сохранено - Нет обработчиков: Новая группа Новая рубрика Новое значение @@ -164,7 +158,7 @@ Обязательно при показе Обязательность настраивается в «Редакторе типа» → Валидация. Ограничить варианты - Описание, использование и доступность каждого типа при создании поля. + Выберите, какие поля будут доступны при настройке рубрик. Основной Основной вывод Основной шаблон @@ -183,7 +177,7 @@ Первые документы рубрики, без сохранения настроек. Перетаскивайте поля за маркер. Перетащите строки за ручку, чтобы изменить порядок. - Плагины полей + Доступные типы полей По выбранным условиям типы не найдены. Подпись Поиск тегов @@ -198,6 +192,7 @@ Половина Поля в компактной сетке Поля рубрики + Поля рубрик После добавления поле исчезает из этого списка. После сохранения документа Последствия восстановления @@ -210,7 +205,6 @@ Проверяется также на сервере продолжают работать. Просмотр структуры рубрик. - Прямые подключения: Публикация и вывод Публичный teaser-шаблон для списков и запросов сайта. Пустая рубрика @@ -225,6 +219,8 @@ Рубрик Рубрика будет создана без дополнительных полей. Рубрики + Справочники + Структура контента Рубрики не найдены. Свой шаблон Свойства выбранного поля. @@ -258,8 +254,7 @@ Таблица Текущий документ: Тип поля - Тип поля хранится в формате рубрик, админский редактор строит adminx. - типов; зарегистрировано + Тип хранится в схеме рубрики; панель управления подбирает для него подходящий редактор. Типы материалов, их поля и оформление. Типы полей Типы полей не найдены. diff --git a/adminx/modules/Rubrics/language/ru/runtime.xml b/adminx/modules/Rubrics/language/ru/runtime.xml index 07e4978..e000ebf 100644 --- a/adminx/modules/Rubrics/language/ru/runtime.xml +++ b/adminx/modules/Rubrics/language/ru/runtime.xml @@ -396,7 +396,7 @@ Синхронизирован набор « Системное имя Скрыть - Сложное поле изображений. Adminx-редактор будет развиваться поверх медиабраузера. + Сложное поле изображений. Редактор панели управления работает поверх медиабраузера. Сначала выполните предварительный просмотр набора Сначала примените миграции рубрик для условий групп Сначала проверьте изменения набора diff --git a/adminx/modules/Rubrics/migrations/014_rubric_purpose.php b/adminx/modules/Rubrics/migrations/014_rubric_purpose.php new file mode 100644 index 0000000..1747826 --- /dev/null +++ b/adminx/modules/Rubrics/migrations/014_rubric_purpose.php @@ -0,0 +1,38 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + + return function () { + $table = ContentTables::table('rubrics'); + $operations = 0; + + if (!DatabaseSchema::columnExists($table, 'rubric_purpose')) { + DB::query( + "ALTER TABLE {$table} ADD COLUMN rubric_purpose VARCHAR(20) CHARACTER SET ascii NOT NULL DEFAULT 'content' AFTER rubric_description" + ); + $operations++; + } + + $index = DB::query("SHOW INDEX FROM {$table} WHERE Key_name = 'idx_rubric_purpose'")->getAssoc(); + if (!$index) { + DB::query("ALTER TABLE {$table} ADD KEY idx_rubric_purpose (rubric_purpose)"); + $operations++; + } + + return $operations; + }; diff --git a/adminx/modules/Rubrics/migrations/015_merge_directory_permissions.php b/adminx/modules/Rubrics/migrations/015_merge_directory_permissions.php new file mode 100644 index 0000000..cf66e55 --- /dev/null +++ b/adminx/modules/Rubrics/migrations/015_merge_directory_permissions.php @@ -0,0 +1,42 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Common\SystemTables; + + return function () { + $permissions = SystemTables::table('permissions'); + $rolePermissions = SystemTables::table('role_permissions'); + $operations = 0; + + if (DatabaseSchema::tableExists($rolePermissions)) { + DB::query( + 'INSERT IGNORE INTO `' . $rolePermissions . '` (role_id, permission_code)' + . ' SELECT role_id, %s FROM `' . $rolePermissions . '` WHERE permission_code = %s', + 'view_rubrics', + 'view_directories' + ); + DB::query('DELETE FROM `' . $rolePermissions . '` WHERE permission_code = %s', 'view_directories'); + $operations += 2; + } + + if (DatabaseSchema::tableExists($permissions)) { + DB::query('DELETE FROM `' . $permissions . '` WHERE code = %s', 'view_directories'); + $operations++; + } + + return $operations; + }; diff --git a/adminx/modules/Rubrics/module.php b/adminx/modules/Rubrics/module.php index 040e90e..69fa23f 100644 --- a/adminx/modules/Rubrics/module.php +++ b/adminx/modules/Rubrics/module.php @@ -17,7 +17,7 @@ return array( 'code' => 'rubrics', 'name' => 'Рубрики и поля', - 'version' => '0.1.0', + 'version' => '0.2.0', 'field_sets' => \App\Adminx\Rubrics\RubricFieldPresets::definitions(), 'hook_definitions' => array( array( @@ -61,6 +61,8 @@ 'sort_order' => 23, 'match' => array( '/rubrics', + '/rubrics/field-sets', + '/directories', ), ), ), @@ -79,10 +81,13 @@ array('id' => '011_schema_revisions', 'file' => 'migrations/011_schema_revisions.sql'), array('id' => '012_linked_field_sets', 'file' => 'migrations/012_linked_field_sets.sql'), array('id' => '013_group_form_conditions', 'file' => 'migrations/013_group_form_conditions.php'), + array('id' => '014_rubric_purpose', 'file' => 'migrations/014_rubric_purpose.php'), + array('id' => '015_merge_directory_permissions', 'file' => 'migrations/015_merge_directory_permissions.php'), ), 'routes' => array( array('GET', '/rubrics', array(\App\Adminx\Rubrics\Controller::class, 'index')), + array('GET', '/rubrics/field-sets', array(\App\Adminx\Rubrics\Controller::class, 'fieldSets')), array('GET', '/rubrics/template-tags', array(\App\Adminx\Rubrics\Controller::class, 'templateTags')), array('GET', '/rubrics/alias-check', array(\App\Adminx\Rubrics\Controller::class, 'rubricAliasCheck')), array('GET', '/rubrics/fields/alias-check', array(\App\Adminx\Rubrics\Controller::class, 'fieldAliasCheck')), diff --git a/adminx/modules/Rubrics/view/_content_tabs.twig b/adminx/modules/Rubrics/view/_content_tabs.twig new file mode 100644 index 0000000..86b02c8 --- /dev/null +++ b/adminx/modules/Rubrics/view/_content_tabs.twig @@ -0,0 +1,14 @@ +{% set section = content_model_section|default('rubrics') %} +{% set interactive = content_model_interactive|default(false) %} + + diff --git a/adminx/modules/Rubrics/view/field-sets.twig b/adminx/modules/Rubrics/view/field-sets.twig new file mode 100644 index 0000000..9380efa --- /dev/null +++ b/adminx/modules/Rubrics/view/field-sets.twig @@ -0,0 +1,75 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Библиотека наборов полей{% endblock %} +{% block content %} + + + + + +
+
{{ stats.total }}Наборов
+
{{ stats.available }}Доступны
+
{{ stats.fields }}Полей всего
+
+ +{% set content_model_section = 'field_sets' %} +{% include '@rubrics/_content_tabs.twig' %} + +
+
+
+
+
Рубрики
+

Готовые схемы

+

Копия становится независимой. Связанный набор можно синхронизировать после обновления его источника.

+
+
+ +
+ {% for item in field_sets %} +
+
+ +

{{ item.title }}

{{ item.description }}

+ {{ item.field_count }} полей +
+
+ {% for group in item.groups %} +
+ {{ group.title }} + {{ group.fields|length }} полей + {% for field in group.fields %}{{ field.title }}{% if not loop.last %}, {% endif %}{% endfor %} +
+ {% endfor %} +
+ {% if can_manage %} +
+ +
+ + +
+ +
+ {% endif %} + {% if item.missing_types %}

Включите типы полей: {{ item.missing_types|join(', ') }}

{% endif %} +
+ {% else %} +
Наборов пока нетОни появятся вместе с ядром или установленными модулями.
+ {% endfor %} +
+
+ +{% endblock %} diff --git a/adminx/modules/Rubrics/view/index.twig b/adminx/modules/Rubrics/view/index.twig index 5f385d1..de3fcfa 100644 --- a/adminx/modules/Rubrics/view/index.twig +++ b/adminx/modules/Rubrics/view/index.twig @@ -30,11 +30,11 @@

Рубрики и поля

Типы материалов, их поля и оформление.

- {% if can_manage %}
+ {% if can_manage %} + {% endif %}
- {% endif %}
@@ -57,12 +57,11 @@ -
- - -
+{% set content_model_section = active_tab %} +{% set content_model_interactive = true %} +{% include '@rubrics/_content_tabs.twig' %} -
+
@@ -159,6 +158,7 @@ {% if rubric.rubric_docs_active == 1 %}активна{% else %}выкл{% endif %} {% if rubric.rubric_meta_gen == '1' %}meta{% endif %} {% if rubric.rubric_alias_history == '1' %}history{% endif %} + {% if rubric.rubric_purpose == 'directory' %}справочник{% endif %} {% if rubric.Id == 1 %}system{% endif %}
@@ -212,26 +212,16 @@
-