AVE.cms 3.3 build 0.35

This commit is contained in:
2026-07-30 11:56:32 +03:00
parent 8e102d3729
commit af9abc0047
304 changed files with 18452 additions and 2660 deletions
+6
View File
@@ -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/
+370 -236
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
# AVE.cms
**AVE.cms 3.3 build 0.32** — модульная система управления сайтами на PHP.
**AVE.cms 3.3 build 0.35** — модульная система управления сайтами на PHP.
Она подходит для проектов, в которых структуру содержимого нужно собирать из
собственных рубрик, полей, документов, запросов, блоков и шаблонов, не
привязываясь к заранее заданному типу сайта.
+91
View File
@@ -0,0 +1,91 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/Support/BulkActionExecutor.php
| @author AVE.cms <support@ave-cms.ru>
| @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;
}
}
+188
View File
@@ -0,0 +1,188 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/Support/GlobalSearchRegistry.php
| @author AVE.cms <support@ave-cms.ru>
| @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());
}
}
+70
View File
@@ -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();
+120
View File
@@ -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)
{
+7 -1
View File
@@ -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, БД или инфраструктуры.', 'Тест создаёт кратковременную нагрузку и сохраняет историю запусков.'), 'Оценивайте не только общий балл, но и конкретный медленный этап.'),
+197 -11
View File
@@ -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 {
+2 -2
View File
@@ -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 @@
'<span class="dialog-icon ' + ic[0] + '"><i class="ti ' + ic[1] + '"></i></span>' +
'<div style="flex:1">' +
'<h3>' + esc(Adminx.tr(cfg.title || Adminx.t('confirm_title', 'Подтвердите действие'))) + '</h3>' +
(cfg.message ? '<p class="text-secondary" style="margin-top:4px">' + esc(Adminx.tr(cfg.message)) + '</p>' : '') +
'</div>' +
'<button class="modal-close" type="button" data-cancel aria-label="' + esc(Adminx.t('btn_close', 'Закрыть')) + '"><i class="ti ti-x"></i></button>' +
'</div>' +
(cfg.message ? '<div class="modal-body confirm-modal-body"><p class="text-secondary">' + esc(Adminx.tr(cfg.message)) + '</p></div>' : '') +
'<div class="modal-footer">' +
'<button class="btn btn-ghost" type="button" data-cancel>' + esc(Adminx.tr(cfg.cancelLabel || Adminx.t('btn_cancel', 'Отмена'))) + '</button>' +
'<button class="btn ' + (cfg.confirmClass || 'btn-primary') + '" type="button" data-ok style="margin-left:auto">' + esc(Adminx.tr(cfg.confirmLabel || Adminx.t('confirm_action', 'Подтвердить'))) + '</button>' +
+1
View File
@@ -0,0 +1 @@
(()=>{(function(h,c){"use strict";var a=h.Adminx||(h.Adminx={});function d(t){var r=t.getAttribute("data-bulk-target")||"";return{items:Array.prototype.slice.call(c.querySelectorAll('[data-bulk-item="'+r+'"]')),all:Array.prototype.slice.call(c.querySelectorAll('[data-bulk-all="'+r+'"]'))}}function u(t){var r=d(t),e=r.items.filter(function(n){return n.checked&&!n.disabled}),l=r.items.filter(function(n){return!n.disabled}),i=t.querySelector("[data-bulk-count]");t.hidden=e.length===0,t.classList.toggle("visible",e.length>0),i&&(i.textContent=e.length),r.all.forEach(function(n){n.checked=l.length>0&&e.length===l.length,n.indeterminate=e.length>0&&e.length<l.length})}function b(t){var r=d(t);r.items.concat(r.all).forEach(function(e){e.checked=!1,e.indeterminate=!1}),u(t)}function k(t){var r=d(t),e=r.items.filter(function(s){return s.checked&&!s.disabled}).map(function(s){return s.value}),l=t.querySelector("[data-bulk-action]"),i=l&&l.options[l.selectedIndex]?l.options[l.selectedIndex]:null;if(!l||!l.value||!e.length){a.Toast.show(a.tr("\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0437\u0430\u043F\u0438\u0441\u0438 \u0438 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435"),"warning");return}var n=t.getAttribute("data-bulk-endpoint")||"",p=i.getAttribute("data-label")||i.textContent||l.value,m=i.getAttribute("data-kind")||"warning";a.Confirm.open({kind:m,title:a.tr("\u0412\u044B\u043F\u043E\u043B\u043D\u0438\u0442\u044C \u043C\u0430\u0441\u0441\u043E\u0432\u043E\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435?"),message:a.tr("\u0412\u044B\u0431\u0440\u0430\u043D\u043E \u0437\u0430\u043F\u0438\u0441\u0435\u0439")+": "+e.length+". "+a.tr("\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u0435")+": "+p+".",confirmLabel:a.tr("\u041F\u0440\u0438\u043C\u0435\u043D\u0438\u0442\u044C"),onConfirm:function(){var s=new FormData;s.append("action",l.value),e.forEach(function(f){s.append("ids[]",f)}),a.Loader.show(),a.Ajax.post(n,s).then(function(f){a.Loader.hide();var g=f.data||{};if(!f.ok||g.success===!1){a.Toast.show(g.message||a.tr("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0432\u044B\u043F\u043E\u043B\u043D\u0438\u0442\u044C \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435"),"error");return}var o=g.data||{},v=(g.message||a.tr("\u0413\u043E\u0442\u043E\u0432\u043E"))+" \xB7 "+a.tr("\u043E\u0431\u0440\u0430\u0431\u043E\u0442\u0430\u043D\u043E")+" "+(o.done||0);o.skipped&&(v+=", "+a.tr("\u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D\u043E")+" "+o.skipped),a.Toast.show(v,o.errors&&o.errors.length?"warning":"success"),c.dispatchEvent(new CustomEvent("adminx:bulk-complete",{detail:{root:t,result:o}})),t.getAttribute("data-bulk-reload")!=="false"?h.location.reload():b(t)}).catch(function(){a.Loader.hide(),a.Toast.show(a.tr("\u0421\u0435\u0440\u0432\u0435\u0440 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u043F\u043E\u043F\u044B\u0442\u043A\u0443."),"error")})}})}c.addEventListener("change",function(t){var r=t.target.closest("[data-bulk-item]"),e=t.target.closest("[data-bulk-all]");if(!(!r&&!e)){var l=(r||e).getAttribute(r?"data-bulk-item":"data-bulk-all"),i=c.querySelector('[data-bulk-actions][data-bulk-target="'+l+'"]');i&&(e&&d(i).items.forEach(function(n){n.disabled||(n.checked=e.checked)}),u(i))}}),c.addEventListener("click",function(t){var r=t.target.closest("[data-bulk-apply]"),e=t.target.closest("[data-bulk-clear]"),l=r||e?(r||e).closest("[data-bulk-actions]"):null;l&&(r?k(l):b(l))}),c.querySelectorAll("[data-bulk-actions]").forEach(u),a.BulkActions={update:u,clear:b}})(window,document);})();
+1
View File
@@ -0,0 +1 @@
(()=>{(function(C,l){"use strict";var i=l.querySelector("[data-command-palette]");if(!i)return;var c=i.querySelector("[data-command-palette-input]"),u=i.querySelector("[data-command-palette-results]"),L=i.getAttribute("data-base")||"",v=[],m=[],a=0,s=null,d=0;try{v=JSON.parse(i.querySelector("[data-command-palette-items]").textContent||"[]")}catch(t){v=[]}function S(t){return String(t||"").toLocaleLowerCase()}function E(t){return t=S(t),v.filter(function(e){return!t||S(e.title+" "+e.subtitle).indexOf(t)!==-1}).slice(0,t?10:18).map(function(e){return{group:"\u0420\u0430\u0437\u0434\u0435\u043B\u044B",title:e.title,subtitle:e.subtitle,url:e.url,icon:e.icon}})}function h(){return E(c.value).concat(m)}function T(t){u.innerHTML="";var e=l.createElement("div");e.className="command-palette-empty",e.innerHTML='<i class="ti ti-search-off"></i><span></span>',e.querySelector("span").textContent=t,u.appendChild(e)}function p(){var t=h();if(!t.length){T(c.value.trim().length<2?"\u041D\u0430\u0447\u043D\u0438\u0442\u0435 \u0432\u0432\u043E\u0434\u0438\u0442\u044C \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435":"\u041D\u0438\u0447\u0435\u0433\u043E \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E");return}a=Math.max(0,Math.min(a,t.length-1)),u.innerHTML="";var e="";t.forEach(function(r,b){if(r.group!==e){var g=l.createElement("div");g.className="command-palette-group",g.textContent=r.group,u.appendChild(g),e=r.group}var n=l.createElement("button");n.className="command-palette-item"+(b===a?" is-active":""),n.type="button",n.setAttribute("role","option"),n.setAttribute("aria-selected",b===a?"true":"false"),n.dataset.index=b,n.innerHTML='<span class="icon-tile" style="--tile-bg:var(--blue-50);--tile-fg:var(--blue-700)"><i class="ti"></i></span><span class="command-palette-copy"><b></b><small></small></span><i class="ti ti-arrow-right"></i>';var f=String(r.icon||"ti-arrow-right").trim();/(^|\s)ti(\s|$)/.test(f)||(f="ti "+f),n.querySelector(".icon-tile .ti").className=f,n.querySelector("b").textContent=r.title||"",n.querySelector("small").textContent=r.subtitle||"",u.appendChild(n)});var o=u.querySelector(".command-palette-item.is-active");o&&o.scrollIntoView({block:"nearest"})}function q(){var t=c.value.trim();m=[],a=0,p(),clearTimeout(s),s=null;var e=++d;t.length<2||(s=setTimeout(function(){fetch(L+"/search?q="+encodeURIComponent(t),{credentials:"same-origin",headers:{Accept:"application/json"}}).then(function(o){return o.json()}).then(function(o){e===d&&(m=o.data&&o.data.items?o.data.items.map(function(r){return r.url=L+r.url,r}):[],p())}).catch(function(){})},180))}function k(){clearTimeout(s),s=null,d++,i.hidden=!1,l.body.classList.add("command-palette-open"),c.value="",m=[],a=0,p(),C.setTimeout(function(){c.focus()},0)}function y(){clearTimeout(s),s=null,d++,i.hidden=!0,l.body.classList.remove("command-palette-open")}function w(t){var e=h()[t];e&&e.url&&C.location.assign(e.url)}l.addEventListener("click",function(t){if(t.target.closest("[data-command-palette-open]")){k();return}if(t.target.closest("[data-command-palette-close]")){y();return}var e=t.target.closest(".command-palette-item");e&&w(parseInt(e.dataset.index,10)||0)}),l.addEventListener("keydown",function(t){if((t.ctrlKey||t.metaKey)&&t.key.toLowerCase()==="k"){t.preventDefault(),i.hidden?k():y();return}i.hidden||(t.key==="Escape"&&(t.preventDefault(),y()),t.key==="ArrowDown"&&(t.preventDefault(),a=Math.min(h().length-1,a+1),p()),t.key==="ArrowUp"&&(t.preventDefault(),a=Math.max(0,a-1),p()),t.key==="Enter"&&(t.preventDefault(),w(a)))}),c.addEventListener("input",q)})(window,document);})();
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -91,7 +91,10 @@
renderCrumbs(data.breadcrumbs || []);
grid.innerHTML = '';
folders.forEach(function (folder) {
grid.insertAdjacentHTML('beforeend', '<button class="media-picker-item is-folder" type="button" data-mp-dir="' + esc(folder.path || '') + '"><span class="media-picker-thumb"><span class="media-picker-folder-icon"><i class="ti ti-folder"></i></span></span><span class="media-picker-meta"><b>' + esc(folder.name || '') + '</b><small><i class="ti ti-files"></i>' + esc(folder.count || 0) + ' объектов</small></span></button>');
var folderMeta = state.q
? '<i class="ti ti-folder-open"></i>' + esc(String(folder.path || '').replace(/^\/uploads\/?/, '') || 'uploads')
: '<i class="ti ti-files"></i>' + esc(folder.count || 0) + ' объектов';
grid.insertAdjacentHTML('beforeend', '<button class="media-picker-item is-folder" type="button" data-mp-dir="' + esc(folder.path || '') + '"><span class="media-picker-thumb"><span class="media-picker-folder-icon"><i class="ti ti-folder"></i></span></span><span class="media-picker-meta"><b>' + esc(folder.name || '') + '</b><small>' + folderMeta + '</small></span></button>');
});
files.forEach(function (file) {
var thumb = file.thumb_url || file.preview_url || file.url || '';
+117
View File
@@ -0,0 +1,117 @@
(function (window, document) {
'use strict';
var Adminx = window.Adminx || (window.Adminx = {});
function controls(root) {
var target = root.getAttribute('data-bulk-target') || '';
return {
items: Array.prototype.slice.call(document.querySelectorAll('[data-bulk-item="' + target + '"]')),
all: Array.prototype.slice.call(document.querySelectorAll('[data-bulk-all="' + target + '"]'))
};
}
function update(root) {
var nodes = controls(root);
var selected = nodes.items.filter(function (item) { return item.checked && !item.disabled; });
var available = nodes.items.filter(function (item) { return !item.disabled; });
var count = root.querySelector('[data-bulk-count]');
root.hidden = selected.length === 0;
root.classList.toggle('visible', selected.length > 0);
if (count) { count.textContent = selected.length; }
nodes.all.forEach(function (all) {
all.checked = available.length > 0 && selected.length === available.length;
all.indeterminate = selected.length > 0 && selected.length < available.length;
});
}
function clear(root) {
var nodes = controls(root);
nodes.items.concat(nodes.all).forEach(function (item) {
item.checked = false;
item.indeterminate = false;
});
update(root);
}
function apply(root) {
var nodes = controls(root);
var ids = nodes.items.filter(function (item) {
return item.checked && !item.disabled;
}).map(function (item) {
return item.value;
});
var select = root.querySelector('[data-bulk-action]');
var option = select && select.options[select.selectedIndex] ? select.options[select.selectedIndex] : null;
if (!select || !select.value || !ids.length) {
Adminx.Toast.show(Adminx.tr('Выберите записи и действие'), 'warning');
return;
}
var endpoint = root.getAttribute('data-bulk-endpoint') || '';
var label = option.getAttribute('data-label') || option.textContent || select.value;
var kind = option.getAttribute('data-kind') || 'warning';
Adminx.Confirm.open({
kind: kind,
title: Adminx.tr('Выполнить массовое действие?'),
message: Adminx.tr('Выбрано записей') + ': ' + ids.length + '. ' + Adminx.tr('Действие') + ': ' + label + '.',
confirmLabel: Adminx.tr('Применить'),
onConfirm: function () {
var data = new FormData();
data.append('action', select.value);
ids.forEach(function (id) { data.append('ids[]', id); });
Adminx.Loader.show();
Adminx.Ajax.post(endpoint, data).then(function (payload) {
Adminx.Loader.hide();
var body = payload.data || {};
if (!payload.ok || body.success === false) {
Adminx.Toast.show(body.message || Adminx.tr('Не удалось выполнить действие'), 'error');
return;
}
var result = body.data || {};
var message = (body.message || Adminx.tr('Готово')) + ' · ' + Adminx.tr('обработано') + ' ' + (result.done || 0);
if (result.skipped) { message += ', ' + Adminx.tr('пропущено') + ' ' + result.skipped; }
Adminx.Toast.show(message, result.errors && result.errors.length ? 'warning' : 'success');
document.dispatchEvent(new CustomEvent('adminx:bulk-complete', {
detail: { root: root, result: result }
}));
if (root.getAttribute('data-bulk-reload') !== 'false') {
window.location.reload();
} else {
clear(root);
}
}).catch(function () {
Adminx.Loader.hide();
Adminx.Toast.show(Adminx.tr('Сервер недоступен. Повторите попытку.'), 'error');
});
}
});
}
document.addEventListener('change', function (event) {
var item = event.target.closest('[data-bulk-item]');
var all = event.target.closest('[data-bulk-all]');
if (!item && !all) { return; }
var target = (item || all).getAttribute(item ? 'data-bulk-item' : 'data-bulk-all');
var root = document.querySelector('[data-bulk-actions][data-bulk-target="' + target + '"]');
if (!root) { return; }
if (all) {
controls(root).items.forEach(function (checkbox) {
if (!checkbox.disabled) { checkbox.checked = all.checked; }
});
}
update(root);
});
document.addEventListener('click', function (event) {
var applyButton = event.target.closest('[data-bulk-apply]');
var clearButton = event.target.closest('[data-bulk-clear]');
var root = (applyButton || clearButton) ? (applyButton || clearButton).closest('[data-bulk-actions]') : null;
if (!root) { return; }
if (applyButton) { apply(root); } else { clear(root); }
});
document.querySelectorAll('[data-bulk-actions]').forEach(update);
Adminx.BulkActions = { update: update, clear: clear };
})(window, document);
+162
View File
@@ -0,0 +1,162 @@
(function (window, document) {
'use strict';
var root = document.querySelector('[data-command-palette]');
if (!root) { return; }
var input = root.querySelector('[data-command-palette-input]');
var results = root.querySelector('[data-command-palette-results]');
var base = root.getAttribute('data-base') || '';
var commands = [];
var remote = [];
var active = 0;
var timer = null;
var request = 0;
try {
commands = JSON.parse(root.querySelector('[data-command-palette-items]').textContent || '[]');
} catch (error) {
commands = [];
}
function normalized(value) {
return String(value || '').toLocaleLowerCase();
}
function localItems(query) {
query = normalized(query);
return commands.filter(function (item) {
return !query || normalized(item.title + ' ' + item.subtitle).indexOf(query) !== -1;
}).slice(0, query ? 10 : 18).map(function (item) {
return {
group: 'Разделы',
title: item.title,
subtitle: item.subtitle,
url: item.url,
icon: item.icon
};
});
}
function allItems() {
return localItems(input.value).concat(remote);
}
function empty(message) {
results.innerHTML = '';
var node = document.createElement('div');
node.className = 'command-palette-empty';
node.innerHTML = '<i class="ti ti-search-off"></i><span></span>';
node.querySelector('span').textContent = message;
results.appendChild(node);
}
function render() {
var items = allItems();
if (!items.length) {
empty(input.value.trim().length < 2 ? 'Начните вводить название' : 'Ничего не найдено');
return;
}
active = Math.max(0, Math.min(active, items.length - 1));
results.innerHTML = '';
var lastGroup = '';
items.forEach(function (item, index) {
if (item.group !== lastGroup) {
var heading = document.createElement('div');
heading.className = 'command-palette-group';
heading.textContent = item.group;
results.appendChild(heading);
lastGroup = item.group;
}
var button = document.createElement('button');
button.className = 'command-palette-item' + (index === active ? ' is-active' : '');
button.type = 'button';
button.setAttribute('role', 'option');
button.setAttribute('aria-selected', index === active ? 'true' : 'false');
button.dataset.index = index;
button.innerHTML = '<span class="icon-tile" style="--tile-bg:var(--blue-50);--tile-fg:var(--blue-700)"><i class="ti"></i></span><span class="command-palette-copy"><b></b><small></small></span><i class="ti ti-arrow-right"></i>';
var iconClass = String(item.icon || 'ti-arrow-right').trim();
if (!/(^|\s)ti(\s|$)/.test(iconClass)) { iconClass = 'ti ' + iconClass; }
button.querySelector('.icon-tile .ti').className = iconClass;
button.querySelector('b').textContent = item.title || '';
button.querySelector('small').textContent = item.subtitle || '';
results.appendChild(button);
});
var selected = results.querySelector('.command-palette-item.is-active');
if (selected) { selected.scrollIntoView({ block: 'nearest' }); }
}
function search() {
var query = input.value.trim();
remote = [];
active = 0;
render();
clearTimeout(timer);
timer = null;
var token = ++request;
if (query.length < 2) { return; }
timer = setTimeout(function () {
fetch(base + '/search?q=' + encodeURIComponent(query), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' }
})
.then(function (response) { return response.json(); })
.then(function (json) {
if (token !== request) { return; }
remote = json.data && json.data.items ? json.data.items.map(function (item) {
item.url = base + item.url;
return item;
}) : [];
render();
})
.catch(function () {});
}, 180);
}
function open() {
clearTimeout(timer);
timer = null;
request++;
root.hidden = false;
document.body.classList.add('command-palette-open');
input.value = '';
remote = [];
active = 0;
render();
window.setTimeout(function () { input.focus(); }, 0);
}
function close() {
clearTimeout(timer);
timer = null;
request++;
root.hidden = true;
document.body.classList.remove('command-palette-open');
}
function use(index) {
var item = allItems()[index];
if (item && item.url) { window.location.assign(item.url); }
}
document.addEventListener('click', function (event) {
if (event.target.closest('[data-command-palette-open]')) { open(); return; }
if (event.target.closest('[data-command-palette-close]')) { close(); return; }
var item = event.target.closest('.command-palette-item');
if (item) { use(parseInt(item.dataset.index, 10) || 0); }
});
document.addEventListener('keydown', function (event) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
root.hidden ? open() : close();
return;
}
if (root.hidden) { return; }
if (event.key === 'Escape') { event.preventDefault(); close(); }
if (event.key === 'ArrowDown') { event.preventDefault(); active = Math.min(allItems().length - 1, active + 1); render(); }
if (event.key === 'ArrowUp') { event.preventDefault(); active = Math.max(0, active - 1); render(); }
if (event.key === 'Enter') { event.preventDefault(); use(active); }
});
input.addEventListener('input', search);
})(window, document);
+1 -1
View File
@@ -478,7 +478,7 @@ var EnhancedImage = Image.extend({
params.set('limit', 40);
status.hidden = false;
status.textContent = 'Загрузка...';
fetch((window.ADMINX_BASE || '/adminx') + '/documents/picker?' + params.toString(), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
fetch((window.ADMINX_BASE || (Adminx.base ? Adminx.base() : '')) + '/documents/picker?' + params.toString(), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(function (response) { return response.json(); })
.then(function (payload) {
if (current !== request) { return; }
+1 -1
View File
@@ -25,7 +25,7 @@
'code' => 'admin_panel',
'group_code' => 'core',
'name' => 'Доступ в панель управления',
'description' => 'Разрешает вход в административную панель /adminx.',
'description' => 'Разрешает вход в панель управления.',
'sort_order' => 1,
),
array(
+18
View File
@@ -119,6 +119,8 @@
AdminAssets::addScript(ADMINX_BASE . '/assets/js/adminx.js', 1);
AdminAssets::addScript(ADMINX_BASE . '/assets/js/media-picker.js', 2);
AdminAssets::addScript(ADMINX_BASE . '/assets/js/saved-views.js', 3);
AdminAssets::addScript(ADMINX_BASE . '/assets/js/command-palette.js', 4);
AdminAssets::addScript(ADMINX_BASE . '/assets/js/bulk-actions.js', 5);
//-- Cache-busting ассетов: ?v=<filemtime>. На время разработки браузер не держит
//-- устаревший CSS/JS — при правке файла (или пересборке CSS) меняется mtime и URL.
@@ -147,9 +149,13 @@
}
$requestPath = '/' . ltrim($requestPath, '/');
$requestQuery = parse_url(isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '', PHP_URL_QUERY);
$requestUri = $requestPath . (is_string($requestQuery) && $requestQuery !== '' ? '?' . $requestQuery : '');
Twig::addGlobal('current_path', $requestPath);
Twig::addGlobal('current_uri', $requestUri);
Twig::addGlobal('section_help', \App\Adminx\Support\SectionHelp::forPath($requestPath));
Twig::addGlobal('module_dependency_notices', \App\Adminx\Support\ModuleExtensions::dependencyNotices($requestPath));
//-- Доступ: публичные роуты (логин) открыты; всё прочее требует admin_panel.
//-- Современный Auth API (SystemTables users: email/password_hash/role);
@@ -189,6 +195,17 @@
}
}
$commandPaletteItems = array();
foreach ($navigationItems as $navItem) {
if (!isset($navItem['url']) || (string) $navItem['url'] === '#') { continue; }
$commandPaletteItems[] = array(
'title' => isset($navItem['label']) ? (string) $navItem['label'] : '',
'subtitle' => isset($navItem['group']) ? (string) $navItem['group'] : '',
'url' => ADMINX_BASE . (string) $navItem['url'],
'icon' => isset($navItem['icon']) ? (string) $navItem['icon'] : 'ti-arrow-right',
);
}
$canClearCache = Permission::check('manage_settings');
$cacheSizes = array();
if ($canClearCache) {
@@ -204,6 +221,7 @@
Twig::addGlobals([
'nav_items' => $navItems,
'command_palette_items' => $commandPaletteItems,
'user_name' => $authUser['name'] ?? '',
'user_email' => $authUser['email'] ?? '',
'user_role' => $authUser['role'] ?? '',
+2
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="action_done">Done</phrase>
<phrase data="auto_1cc70f8e0fc03e82">Delete view</phrase>
<phrase data="auto_86433d57abc272e9">Saved Views</phrase>
<phrase data="btn_apply">Apply</phrase>
@@ -37,6 +38,7 @@
<phrase data="ui_remove">Remove</phrase>
<phrase data="ui_search">Search</phrase>
<phrase data="ui_select">Select</phrase>
<phrase data="ui_selected">Selected</phrase>
<phrase data="ui_size">Size</phrase>
<phrase data="ui_source">Source</phrase>
<phrase data="ui_title">Title</phrase>
+1 -1
View File
@@ -368,7 +368,7 @@
<phrase data="runtime_87e4da4d68321ed6">Unlocking returns access immediately.</phrase>
<phrase data="runtime_58be617699e0fe18">Sections define the directory structure and the set of available fields.</phrase>
<phrase data="runtime_56274d1ca16887fe">Allows you to see a public site when it is temporarily closed to visitors and search engines.</phrase>
<phrase data="runtime_c3995f01449595d9">Allows login to the administrative panel /adminx.</phrase>
<phrase data="runtime_c3995f01449595d9">Allows login to the control panel.</phrase>
<phrase data="runtime_8ad60574b1f35312">Allow only those types of objects on which discussions are actually needed.</phrase>
<phrase data="runtime_3b7123a2c037efc8">Distributes visitors between options and collects impressions and conversions without mixing options in a shared cache.</phrase>
<phrase data="runtime_618a47736f884790">The calculation is included separately and uses only fully filled packaging.</phrase>
+2
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="action_done">Готово</phrase>
<phrase data="auto_1cc70f8e0fc03e82">Удалить представление</phrase>
<phrase data="auto_86433d57abc272e9">Сохранённые представления</phrase>
<phrase data="btn_apply">Применить</phrase>
@@ -37,6 +38,7 @@
<phrase data="ui_remove">Убрать</phrase>
<phrase data="ui_search">Поиск</phrase>
<phrase data="ui_select">Выбрать</phrase>
<phrase data="ui_selected">Выбрано</phrase>
<phrase data="ui_size">Размер</phrase>
<phrase data="ui_source">Источник</phrase>
<phrase data="ui_title">Заголовок</phrase>
+1 -1
View File
@@ -368,7 +368,7 @@
<phrase data="runtime_87e4da4d68321ed6">Разблокировка возвращает доступ сразу.</phrase>
<phrase data="runtime_58be617699e0fe18">Разделы задают структуру каталога и набор доступных полей.</phrase>
<phrase data="runtime_56274d1ca16887fe">Разрешает видеть публичный сайт, когда он временно закрыт для посетителей и поисковых систем.</phrase>
<phrase data="runtime_c3995f01449595d9">Разрешает вход в административную панель /adminx.</phrase>
<phrase data="runtime_c3995f01449595d9">Разрешает вход в панель управления.</phrase>
<phrase data="runtime_8ad60574b1f35312">Разрешите только те типы объектов, на которых действительно нужны обсуждения.</phrase>
<phrase data="runtime_3b7123a2c037efc8">Распределяет посетителей между вариантами и собирает показы и конверсии без смешивания вариантов в общем кеше.</phrase>
<phrase data="runtime_618a47736f884790">Расчёт включается отдельно и использует только полностью заполненную упаковку.</phrase>
+1 -1
View File
@@ -38,6 +38,6 @@
</form>
{% endfor %}
</div>
<div class="auth-foot text-muted text-xs">AVE.cms · /adminx</div>
<div class="auth-foot text-muted text-xs">AVE.cms · Панель управления</div>
</div>
{% endblock %}
+10
View File
@@ -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) {
+29 -104
View File
@@ -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;
}
}
-2
View File
@@ -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;
+2
View File
@@ -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);
},
File diff suppressed because it is too large Load Diff
@@ -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;
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -38,7 +38,7 @@
<div class="catalog-card-body"><div class="form-grid">
<label class="field col-3"><span class="field-label">Назначение каталога</span><select class="select" name="purpose"><option value="content"{{ settings.purpose == 'content' ? ' selected' : '' }}>Обычный каталог</option>{% if products_available %}<option value="commerce"{{ settings.purpose == 'commerce' ? ' selected' : '' }}>Товарный каталог</option>{% endif %}</select><span class="field-hint">{% if products_available %}Товарный режим включает коммерческий индекс, цены, остатки, варианты и фиды.{% else %}Товарный режим доступен после установки модуля «Товары».{% endif %}</span></label>
<label class="field col-3"><span class="field-label">Запрос фильтра</span><select class="select" name="request_id"><option value="0">Не выбран</option>{% for item in request_options %}<option value="{{ item.id }}"{{ settings.request_id == item.id ? ' selected' : '' }}>{{ item.title ?: item.alias }}{% if item.alias and item.alias != item.title %} · {{ item.alias }}{% endif %} (#{{ item.id }})</option>{% endfor %}</select><span class="field-hint">Запрос, который формирует выдачу и условия фильтрации.</span></label>
<label class="field col-3"><span class="field-label">Навигация</span><select class="select" name="navi_id"><option value="0">Не связана</option>{% for item in navigation_options %}<option value="{{ item.id }}"{{ settings.navi_id == item.id ? ' selected' : '' }}>{{ item.title ?: item.alias }}{% if item.alias and item.alias != item.title %} · {{ item.alias }}{% endif %} (#{{ item.id }})</option>{% endfor %}</select><span class="field-hint">Меню, используемое для экспорта структуры каталога.</span></label>
<label class="field col-3"><span class="field-label">Навигация в шапке</span><select class="select" name="navi_id"><option value="0">Не связана</option>{% for item in navigation_options %}<option value="{{ item.id }}"{{ settings.navi_id == item.id ? ' selected' : '' }}>{{ item.title ?: item.alias }}{% if item.alias and item.alias != item.title %} · {{ item.alias }}{% endif %} (#{{ item.id }})</option>{% endfor %}</select><span class="field-hint">Дополнительные ссылки и подборки перед разделами каталога.</span></label>
<label class="field col-3"><span class="field-label">Рубрика разделов</span><select class="select" name="rub_cat_id"><option value="0">Не выбрана</option>{% for item in rubric_options %}<option value="{{ item.id }}"{{ settings.rub_cat_id == item.id ? ' selected' : '' }}>{{ item.title ?: item.alias }}{% if item.alias and item.alias != item.title %} · {{ item.alias }}{% endif %} (#{{ item.id }})</option>{% endfor %}</select><span class="field-hint">Рубрика документов, связанных с разделами каталога.</span></label>
</div>
{% if products_available %}<section class="catalog-commerce-fields" data-catalog-commerce-fields><div class="catalog-behavior-head"><span class="icon-tile catalog-behavior-icon is-green"><i class="ti ti-shopping-bag"></i></span><h3>Поля товарного представления</h3></div><div class="form-grid">{% 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 %}<label class="field col-4"><span class="field-label">{{ label }}</span><select class="select" name="{{ key }}"><option value="0">Не назначено</option>{% for group in field_groups %}{% for field in group.items %}<option value="{{ field.id }}"{{ attribute(settings,key) == field.id ? ' selected' : '' }}>{{ field.title ?: field.alias }} · #{{ field.id }}</option>{% endfor %}{% endfor %}</select></label>{% endfor %}</div></section>
@@ -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;
}
@@ -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);
+12 -5
View File
@@ -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);
+1 -1
View File
@@ -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');
}
@@ -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;
+1 -1
View File
@@ -151,7 +151,7 @@
<span class="cluster" style="gap:8px">
<i class="ti ti-file-zip text-muted"></i>
<span class="mono text-sm">{{ b.name }}</span>
{% if b.own %}<span class="badge badge-blue">adminx</span>{% endif %}
{% if b.own %}<span class="badge badge-blue">AVE.cms</span>{% endif %}
</span>
</td>
<td class="text-secondary">{{ b.size_h }}</td>
+189
View File
@@ -0,0 +1,189 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Directories/Controller.php
| @author AVE.cms <support@ave-cms.ru>
| @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,
));
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Directories/Model.php
| @author AVE.cms <support@ave-cms.ru>
| @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();
}
}
@@ -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;
}
}
@@ -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);
@@ -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;
+40
View File
@@ -0,0 +1,40 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Directories/module.php
| @author AVE.cms <support@ave-cms.ru>
| @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',
),
);
+212
View File
@@ -0,0 +1,212 @@
{% extends '@adminx/main.twig' %}
{% block title %}Справочники{% endblock %}
{% block content %}
<nav class="breadcrumbs" aria-label="Хлебные крошки">
<a href="{{ ADMINX_BASE }}/">Главная</a><i class="ti ti-chevron-right"></i>
<a href="{{ ADMINX_BASE }}/rubrics">Рубрики и поля</a><i class="ti ti-chevron-right"></i>
<span>Справочники</span>
</nav>
<div class="page-header">
<div class="between">
<div>
<h1>Справочники</h1>
<p class="text-secondary">Общие списки значений, которые можно подключать к нескольким полям.</p>
</div>
{% if can_manage and schema_ready %}
<button class="btn btn-primary" type="button" data-directory-new><i class="ti ti-plus"></i>Создать справочник</button>
{% endif %}
</div>
</div>
{% if not schema_ready %}
<div class="alert alert-warning">
<i class="ti ti-database-exclamation alert-ic"></i>
<div>
<b>Схема справочников ещё не готова</b>
<p>Примените миграции ядра в разделе «База данных → Миграции».</p>
</div>
</div>
{% endif %}
<div class="directories-summary">
<div class="card directories-stat"><span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-books"></i></span><div><b>{{ stats.total }}</b><span>Справочников</span></div></div>
<div class="card directories-stat"><span class="icon-tile" style="--tile-bg:var(--green-100);--tile-fg:var(--green-600)"><i class="ti ti-list-check"></i></span><div><b>{{ stats.values }}</b><span>Значений</span></div></div>
</div>
{% set content_model_section = 'directories' %}
{% include '@rubrics/_content_tabs.twig' %}
<section class="rubrics-panel directories-panel" data-directories-page data-base="{{ ADMINX_BASE }}">
<div class="section-header rubrics-panel-header">
<div class="section-icon"><i class="ti ti-books"></i></div>
<div>
<div class="section-eyebrow">Поля рубрик</div>
<h2>Общие справочники</h2>
<p class="section-desc">Один список можно подключить к нескольким полям и менять централизованно.</p>
</div>
</div>
<div class="card rubrics-card rubrics-filter-card">
<div class="rubrics-section-head">
<div class="rubrics-section-title">
<span class="icon-tile rubrics-head-icon" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-table"></i></span>
<div>
<h2>Справочники</h2>
<p class="text-secondary">{{ directories|length }} в текущем фильтре, {{ stats.total }} всего.</p>
</div>
</div>
<span class="badge badge-blue">{{ directories|length }}</span>
</div>
<div class="table-toolbar rubrics-toolbar">
<form class="rubrics-filter directories-filter" method="get" action="{{ ADMINX_BASE }}/directories">
<div class="input-wrap"><i class="ti ti-search"></i><input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Название или код"></div>
<button class="btn btn-secondary" type="submit"><i class="ti ti-search"></i>Найти</button>
{% if filters.q %}<a class="btn btn-ghost" href="{{ ADMINX_BASE }}/directories"><i class="ti ti-x"></i>Сбросить</a>{% endif %}
</form>
</div>
</div>
<div class="card rubrics-card">
<div class="rubrics-section-head">
<div class="rubrics-section-title">
<span class="icon-tile rubrics-head-icon" style="--tile-bg:var(--violet-100);--tile-fg:var(--violet-600)"><i class="ti ti-list-details"></i></span>
<div>
<h2>Список справочников</h2>
<p class="text-secondary">Ключ хранится в документе, подпись можно менять без перезаписи документов.</p>
</div>
</div>
</div>
<div class="table-scroll">
<table class="table table-compact rubrics-table directories-table">
<colgroup>
<col class="directories-col-name">
<col class="directories-col-code">
<col class="directories-col-values">
<col class="directories-col-usage">
<col class="directories-col-state">
<col class="directories-col-actions">
</colgroup>
<thead><tr><th>Название</th><th>Код</th><th>Значения</th><th>Используется</th><th>Состояние</th><th></th></tr></thead>
<tbody>
{% for item in directories %}
<tr{{ selected and selected.id == item.id ? ' class="is-selected"' : '' }}>
<td class="directories-cell-name">
<div class="directories-name">
<b>{{ item.name }}</b>
{% if item.description %}<small>{{ item.description }}</small>{% endif %}
</div>
</td>
<td class="directories-cell-code" data-label="Код"><code>{{ item.code }}</code></td>
<td class="directories-cell-values" data-label="Значения">{{ item.items_count }}</td>
<td class="directories-cell-usage" data-label="Используется">{% if item.usage_count %}<span class="badge badge-blue">{{ item.usage_count }} полей</span>{% else %}<span class="text-secondary">Не подключён</span>{% endif %}</td>
<td class="directories-cell-state" data-label="Состояние">{% if item.is_active %}<span class="badge badge-green">активен</span>{% else %}<span class="badge badge-gray">выключен</span>{% endif %}</td>
<td class="directories-cell-actions">
<div class="cluster directories-actions">
<a class="btn btn-ghost btn-icon btn-sm directories-action-values" href="{% if selected and selected.id == item.id %}#directory-values{% else %}{{ ADMINX_BASE }}/directories?id={{ item.id }}{% if filters.q %}&q={{ filters.q|url_encode }}{% endif %}#directory-values{% endif %}" data-tooltip="Открыть значения" aria-label="Открыть значения"><i class="ti ti-list-details"></i></a>
{% if can_manage %}
<button class="btn btn-ghost btn-icon btn-sm directories-action-edit" type="button" data-directory-edit
data-id="{{ item.id }}" data-name="{{ item.name|e('html_attr') }}" data-code="{{ item.code|e('html_attr') }}"
data-description="{{ item.description|e('html_attr') }}" data-active="{{ item.is_active ? '1' : '0' }}" data-usage="{{ item.usage_count }}"
data-tooltip="Настроить" aria-label="Настроить"><i class="ti ti-settings"></i></button>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr><td class="empty-state-cell" colspan="6"><div class="empty-state"><i class="ti ti-books"></i><b>Справочников пока нет</b><span>Создайте общий список значений для полей.</span></div></td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if selected %}
<div class="card rubrics-card directories-values-card" id="directory-values">
<div class="rubrics-section-head">
<div class="rubrics-section-title">
<span class="icon-tile rubrics-head-icon" style="--tile-bg:var(--green-100);--tile-fg:var(--green-600)"><i class="ti ti-list-check"></i></span>
<div>
<h2>Значения: {{ selected.name }}</h2>
<p class="text-secondary"><code>{{ selected.code }}</code> · {{ selected.usage_count }} полей · {{ items|length }} значений</p>
</div>
</div>
{% if can_manage %}
<div class="cluster directories-head-actions">
<button class="btn btn-secondary btn-icon" type="button" data-directory-edit
data-id="{{ selected.id }}" data-name="{{ selected.name|e('html_attr') }}" data-code="{{ selected.code|e('html_attr') }}"
data-description="{{ selected.description|e('html_attr') }}" data-active="{{ selected.is_active ? '1' : '0' }}" data-usage="{{ selected.usage_count }}"
data-tooltip="Настроить справочник" aria-label="Настроить справочник"><i class="ti ti-settings"></i></button>
<button class="btn btn-primary" type="button" data-directory-item-new><i class="ti ti-plus"></i>Добавить значение</button>
</div>
{% endif %}
</div>
<div class="table-scroll">
<table class="table table-compact directories-items-table">
<colgroup><col class="directories-item-order"><col class="directories-item-key"><col><col class="directories-item-state"><col class="directories-item-actions"></colgroup>
<thead><tr><th>Порядок</th><th>Ключ</th><th>Подпись</th><th>Состояние</th><th></th></tr></thead>
<tbody>
{% for value in items %}
<tr{{ value.is_active ? '' : ' class="is-muted"' }}>
<td class="mono text-muted">{{ value.sort_order }}</td>
<td><code>{{ value.item_key }}</code></td>
<td><b>{{ value.label }}</b></td>
<td>{% if value.is_active %}<span class="badge badge-green">активно</span>{% else %}<span class="badge badge-gray">выключено</span>{% endif %}</td>
<td><div class="cluster directories-actions">
{% if can_manage %}
<button class="btn btn-ghost btn-icon btn-sm directories-action-edit" type="button" data-directory-item-edit
data-id="{{ value.id }}" data-key="{{ value.item_key|e('html_attr') }}" data-label="{{ value.label|e('html_attr') }}"
data-order="{{ value.sort_order }}" data-active="{{ value.is_active ? '1' : '0' }}"
data-tooltip="Изменить" aria-label="Изменить"><i class="ti ti-pencil"></i></button>
<button class="btn btn-danger-soft btn-icon btn-sm" type="button" data-directory-item-delete="{{ value.id }}" data-label="{{ value.label|e('html_attr') }}" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button>
{% endif %}
</div></td>
</tr>
{% else %}
<tr><td class="empty-state-cell" colspan="5"><div class="empty-state"><i class="ti ti-list"></i><b>Значений пока нет</b><span>Добавьте первую пару «ключ → подпись».</span></div></td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</section>
{% if can_manage %}
<aside class="drawer drawer-right" id="directoryDrawer" role="dialog" aria-modal="true" aria-labelledby="directoryDrawerTitle" hidden>
<form data-directory-form>
<div class="drawer-header"><div><h3 id="directoryDrawerTitle" data-directory-form-title>Новый справочник</h3><p class="text-secondary text-sm">Общий источник значений для полей.</p></div><button class="btn btn-ghost btn-icon" type="button" data-close-drawer aria-label="Закрыть"><i class="ti ti-x"></i></button></div>
<div class="drawer-body">
<input type="hidden" name="_csrf" value="{{ csrf_token }}"><input type="hidden" name="id" value="">
<div class="form-grid">
<label class="field col-12"><span class="field-label">Название</span><div class="input-wrap"><i class="ti ti-book-2"></i><input class="input" name="name" required maxlength="190" placeholder="Например, Производители"></div></label>
<label class="field col-12"><span class="field-label">Код</span><div class="input-wrap"><i class="ti ti-code"></i><input class="input mono" name="code" maxlength="64" pattern="[a-z0-9][a-z0-9_-]{1,63}" placeholder="manufacturers"></div><span class="field-hint">Стабильный технический код. Если оставить пустым, сформируется из названия.</span></label>
<label class="field col-12"><span class="field-label">Описание</span><textarea class="textarea" name="description" rows="4" placeholder="Где и для чего используется список"></textarea></label>
<div class="field col-12"><span class="field-label">Состояние</span><label class="switch"><input type="checkbox" name="is_active" value="1" checked><span class="switch-track"></span><span class="switch-label">Доступен для выбора в полях</span></label></div>
</div>
</div>
<div class="drawer-footer"><button class="btn btn-danger-soft" type="button" data-directory-delete hidden><i class="ti ti-trash"></i>Удалить</button><button class="btn btn-secondary" type="button" data-close-drawer>Закрыть</button><button class="btn btn-primary" type="submit" style="margin-left:auto"><i class="ti ti-device-floppy"></i>Сохранить</button></div>
</form>
</aside>
{% if selected %}
<aside class="drawer drawer-right" id="directoryItemDrawer" role="dialog" aria-modal="true" aria-labelledby="directoryItemDrawerTitle" hidden>
<form data-directory-item-form data-directory="{{ selected.id }}">
<div class="drawer-header"><div><h3 id="directoryItemDrawerTitle" data-directory-item-title>Новое значение</h3><p class="text-secondary text-sm">{{ selected.name }}</p></div><button class="btn btn-ghost btn-icon" type="button" data-close-drawer aria-label="Закрыть"><i class="ti ti-x"></i></button></div>
<div class="drawer-body">
<input type="hidden" name="_csrf" value="{{ csrf_token }}"><input type="hidden" name="id" value="">
<div class="form-grid">
<label class="field col-12"><span class="field-label">Подпись</span><div class="input-wrap"><i class="ti ti-forms"></i><input class="input" name="label" required maxlength="255" placeholder="Например, Россия"></div><span class="field-hint">Эту подпись увидят редактор и посетитель сайта.</span></label>
<label class="field col-8"><span class="field-label">Стабильный ключ</span><div class="input-wrap"><i class="ti ti-key"></i><input class="input mono" name="item_key" maxlength="120" pattern="[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,119}" placeholder="russia"></div><span class="field-hint">Хранится в документе. После начала использования ключ лучше не менять.</span></label>
<label class="field col-4"><span class="field-label">Порядок</span><input class="input" type="number" min="0" name="sort_order" value="0"></label>
<div class="field col-12"><span class="field-label">Состояние</span><label class="switch"><input type="checkbox" name="is_active" value="1" checked><span class="switch-track"></span><span class="switch-label">Показывать в полях</span></label></div>
</div>
</div>
<div class="drawer-footer"><button class="btn btn-secondary" type="button" data-close-drawer>Закрыть</button><button class="btn btn-primary" type="submit" style="margin-left:auto"><i class="ti ti-device-floppy"></i>Сохранить</button></div>
</form>
</aside>
{% endif %}
{% endif %}
{% endblock %}
+45 -24
View File
@@ -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) {
+109 -76
View File
@@ -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[<Id>]' => сообщение]. Пусто, если правил нет.
*/
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 = '<div class="documents-catalog-field" data-document-catalog-field="' . $id . '" data-catalog-limits-fields="' . ((int) $settings['doc_fileds'] === 1 ? '1' : '0') . '">'
. '<input type="hidden" name="fields[' . $id . '][catalog_ids][]" value="">'
. '<div class="documents-catalog-tokens" data-document-catalog-tokens>' . $tokens . '</div>'
. '<p class="documents-catalog-empty"' . (!empty($selectedIds) ? ' hidden' : '') . ' data-document-catalog-empty>Разделы не выбраны.</p>'
. '<p class="documents-catalog-empty"' . (!empty($selectedIds) ? ' hidden' : '') . ' data-document-catalog-empty>' . $emptyLabel . '</p>'
. '<div class="dropdown documents-catalog-dropdown">'
. '<button class="btn btn-secondary btn-sm" type="button" data-dropdown data-document-catalog-add><i class="ti ti-plus"></i>Добавить раздел</button>'
. '<button class="btn btn-secondary btn-sm" type="button" data-dropdown data-document-catalog-add><i class="ti ti-plus"></i>' . $addLabel . '</button>'
. '<div class="dropdown-menu documents-catalog-menu">'
. '<label class="input-wrap documents-catalog-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="Найти раздел" data-document-catalog-search></label>'
. '<label class="input-wrap documents-catalog-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="' . $searchLabel . '" data-document-catalog-search></label>'
. '<ol class="documents-catalog-tree">' . self::renderCatalogNodes($tree, $id, $selected) . '</ol>'
. '</div></div></div>';
return $html;
@@ -2213,18 +2243,21 @@
protected static function catalogTokenHtml($fieldId, $catalogId, $name, $fields)
{
$removeLabel = AdminLocale::translateMarkup('Убрать раздел');
return '<span class="documents-catalog-token" data-document-catalog-token data-catalog-id="' . (int) $catalogId
. '" data-catalog-fields="' . self::e($fields) . '">'
. '<i class="ti ti-folder documents-catalog-token-icon" aria-hidden="true"></i>'
. '<span class="documents-catalog-token-name">' . self::e($name) . '</span>'
. '<input type="hidden" name="fields[' . (int) $fieldId . '][catalog_ids][]" value="' . (int) $catalogId . '">'
. '<button class="documents-catalog-token-remove" type="button" data-document-catalog-remove aria-label="Убрать раздел"><i class="ti ti-x"></i></button>'
. '<button class="documents-catalog-token-remove" type="button" data-document-catalog-remove aria-label="' . $removeLabel . '"><i class="ti ti-x"></i></button>'
. '</span>';
}
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 @@
. '<button class="documents-catalog-option' . ($isSelected ? ' is-picked' : '') . '" type="button" data-document-catalog-option'
. ' data-catalog-id="' . (int) $item['id'] . '" data-catalog-name="' . self::e($item['name']) . '" data-catalog-fields="' . self::e($fields) . '">'
. '<span class="documents-catalog-option-main"><b>' . self::e($item['name']) . '</b><small>#' . (int) $item['id']
. ($item['document_id'] > 0 ? ' · документ #' . (int) $item['document_id'] : '') . '</small></span>'
. ((int) $item['status'] === 1 ? '' : '<span class="badge badge-gray">скрыт</span>') . '</button>';
. ($item['document_id'] > 0 ? ' · ' . $documentLabel . (int) $item['document_id'] : '') . '</small></span>'
. ((int) $item['status'] === 1 ? '' : '<span class="badge badge-gray">' . $hiddenLabel . '</span>') . '</button>';
if (!empty($item['children'])) { $html .= '<ol>' . self::renderCatalogNodes($item['children'], $fieldId, $selected) . '</ol>'; }
$html .= '</li>';
}
+121 -27
View File
@@ -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;
}
}
+101 -31
View File
@@ -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 () {
+3 -1
View File
@@ -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') : '';
@@ -1,47 +1,290 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="auto_003cc12783448df3">x</phrase>
<phrase data="auto_0156bf1b8aac8565">Add more...</phrase>
<phrase data="auto_02b4ebb5dc50bca5">The address of the document will be written into the link field.</phrase>
<phrase data="auto_02fdd4b8923613d6">Up to 10 characters, optional.</phrase>
<phrase data="auto_04c0389bc0db2353">Token copied</phrase>
<phrase data="auto_080887ebc7bb5402">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm documents-media-remove&quot; type=&quot;button&quot; data-document-media-remove data-tooltip=&quot;Delete&quot; aria-label=&quot;Delete&quot;&gt;&lt;i class=&quot;ti ti-trash&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_0957de938411c15b">field</phrase>
<phrase data="auto_0af969e08c220502">Clear all field lines?</phrase>
<phrase data="auto_0db49b55557f2bde">Parameter</phrase>
<phrase data="auto_0e7fbe3404b67fe3">Rebuild JSON snapshots?</phrase>
<phrase data="auto_0f9ccee053fe3b6b">New</phrase>
<phrase data="auto_0ff730d95f9c5bc3">click to select</phrase>
<phrase data="auto_122f4893d3d0d802">Failed to check alias</phrase>
<phrase data="auto_1291e1971ba28ee6">API token created</phrase>
<phrase data="auto_12bfabff3d7dc01c">Add a keyword</phrase>
<phrase data="auto_18337ef195f7d88d">&lt;div class=&quot;modal-body&quot;&gt;&lt;div class=&quot;input-wrap documents-relation-search&quot;&gt;&lt;i class=&quot;ti ti-search&quot;&gt;&lt;/i&gt;&lt;input class=&quot;input&quot; type=&quot;search&quot; placeholder=&quot;ID, title or alias&quot; data-relation-search&gt;&lt;/div&gt;&lt;div class=&quot;documents-picker-status&quot; data-relation-status&gt;Loading...&lt;/div&gt;&lt;div class=&quot;documents-relation-list&quot; data-relation-list&gt;&lt;/div&gt;&lt;/div&gt;</phrase>
<phrase data="auto_1894f01f2cfa4702">JSON snapshot</phrase>
<phrase data="auto_1a7725e697463fd8">to</phrase>
<phrase data="auto_1af72c14888c4574">Creation preset</phrase>
<phrase data="auto_1bc16af35fa47103">uh</phrase>
<phrase data="auto_1d00a5603a94c17d">Teaser ID/link</phrase>
<phrase data="auto_1d402bc8ffda29cd">Internal server error. Please refresh the page and try again.</phrase>
<phrase data="auto_1f864100c5c97f02">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-down data-tooltip=&quot;Below&quot; aria-label=&quot;Below&quot;&gt;&lt;i class=&quot;ti ti-arrow-down&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_22dc89ab84f07324">&quot;&gt;&lt;button class=&quot;btn btn-secondary btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-pick data-tooltip=&quot;Select file&quot; aria-label=&quot;Select file&quot;&gt;&lt;i class=&quot;ti ti-</phrase>
<phrase data="auto_230b5877294e9726">ts</phrase>
<phrase data="auto_23289c89f8c038a0">The document ID will be written in the field.</phrase>
<phrase data="auto_242f64ddfe84899b">after saving</phrase>
<phrase data="auto_25a9845975119103">Creation preset saved</phrase>
<phrase data="auto_25dc101484dbd55c">Failed to load list</phrase>
<phrase data="auto_285cc4001478d568">h</phrase>
<phrase data="auto_28c993e4935524d8">h</phrase>
<phrase data="auto_28fb6167ec0a79fe">&lt;section class=&quot;documents-revision-system&quot;&gt;&lt;div class=&quot;documents-revision-subhead&quot;&gt;&lt;i class=&quot;ti ti-settings&quot;&gt;&lt;/i&gt;&lt;b&gt;Basic settings&lt;/b&gt;&lt;span&gt;</phrase>
<phrase data="auto_292688e9915e161b">[link]&quot; data-media-key=&quot;link&quot; value=&quot;&quot; placeholder=&quot;Link or document&quot; data-document-media-url data-document-picker-type=&quot;all&quot;&gt;</phrase>
<phrase data="auto_29f3b29df0d0e54f">Restore</phrase>
<phrase data="auto_2a9790fd6fab2922">&lt;button class=&quot;btn btn-secondary btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-doc-pick data-tooltip=&quot;Select document&quot; aria-label=&quot;Select document&quot;&gt;&lt;i class=&quot;ti ti-file-search&quot;&gt;&lt;/i&gt;&lt;/button&gt;&lt;/div&gt;</phrase>
<phrase data="auto_2ba58b00b4c52021">&lt;/span&gt;&lt;label class=&quot;documents-revision-group-check&quot;&gt;&lt;input type=&quot;checkbox&quot; data-document-revision-group=&quot;field&quot; checked&gt;&lt;span&gt;All&lt;/span&gt;&lt;/label&gt;&lt;/div&gt;</phrase>
<phrase data="auto_2c501dc73177148a">ID, name or alias</phrase>
<phrase data="auto_2e31dae95f35e3b8">Select file</phrase>
<phrase data="auto_2ec20105a2757791">Column 3</phrase>
<phrase data="auto_2f1256a9610f7d64">Redirect saved</phrase>
<phrase data="auto_2ff1fe42207df255">The secret is ready to be copied.</phrase>
<phrase data="auto_301361625f901825">The server returned an incorrect response.</phrase>
<phrase data="auto_30278b05221dd957">Refresh page</phrase>
<phrase data="auto_32b74a3c47908c1e">Untitled</phrase>
<phrase data="auto_33392e5848b4a8eb">JSON snapshots reassembled:</phrase>
<phrase data="auto_336d12a4f96485a3">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-up data-tooltip=&quot;Above&quot; aria-label=&quot;Above&quot;&gt;&lt;i class=&quot;ti ti-arrow-up&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_359282f9ef75e654">Document ID</phrase>
<phrase data="auto_35de32df9d942d9a">Note added</phrase>
<phrase data="auto_3697bef0b3fc93f5">new</phrase>
<phrase data="auto_3812a04b189ec1fd">new</phrase>
<phrase data="auto_39704f311a86cd23">Draft</phrase>
<phrase data="auto_3de49828e86a4016">Title</phrase>
<phrase data="auto_3e096d0063299173">Revision deleted</phrase>
<phrase data="auto_3e25fbc59741a10a">Document title</phrase>
<phrase data="auto_3e670a1ec591883c">Files uploaded</phrase>
<phrase data="auto_3eb77d3c13c51f92">Select revision</phrase>
<phrase data="auto_3f039532d65bb5e3">Rebuild JSON</phrase>
<phrase data="auto_4044fbc87708b9ad">Clear daily statistics?</phrase>
<phrase data="auto_40785832c27c3a51">Column 1</phrase>
<phrase data="auto_42ba924b3732dc07">fields</phrase>
<phrase data="auto_433e4b40a403cdb0">th</phrase>
<phrase data="auto_447d642f14fb2c8c">There are no matching files in the folder</phrase>
<phrase data="auto_46551a5e6e43e854">Statistics cleared</phrase>
<phrase data="auto_48be85b47e187f3c">not created yet</phrase>
<phrase data="auto_4a05d8619dedc462">Copy</phrase>
<phrase data="auto_4b4c88ed13b23b15">Redirect removed</phrase>
<phrase data="auto_4d60d98f499d1580">Audits</phrase>
<phrase data="auto_4f566fb5e3df4276">I</phrase>
<phrase data="auto_516cbe2af24391af">l</phrase>
<phrase data="auto_51b4eac98af84251">a</phrase>
<phrase data="auto_51f784cde1916288">b</phrase>
<phrase data="auto_522e27d7402089bc">Document saved</phrase>
<phrase data="auto_53e908b396e3a6b3">&lt;span class=&quot;badge badge-gray&quot;&gt;not created&lt;/span&gt;</phrase>
<phrase data="auto_54a8375d14dfdab5">Failed to read folder</phrase>
<phrase data="auto_56ac0cff0f4a358f">Note deleted</phrase>
<phrase data="auto_576895a59897535b">File not selected</phrase>
<phrase data="auto_577021b5ac1fd915">f</phrase>
<phrase data="auto_57afad1302f9645e">o</phrase>
<phrase data="auto_588c8feeb973dcb1">&lt;div&gt;&lt;dt&gt;File&lt;/dt&gt;&lt;dd class=&quot;mono&quot;&gt;</phrase>
<phrase data="auto_59ddef8a55503c83">kg</phrase>
<phrase data="auto_5a3b4bcf55400711">r</phrase>
<phrase data="auto_5bd0d9fe0580526f">. The window must be left open until completion.</phrase>
<phrase data="auto_5c019fa5d03ca5cf">in the section</phrase>
<phrase data="auto_5e6c96299a2f11e9">s</phrase>
<phrase data="auto_5e6d6b07b27b7a7c">All view_count lines will be removed. General document counters will remain unchanged.</phrase>
<phrase data="auto_5f9eaa37c4e2ccff">&lt;button class=&quot;btn btn-secondary btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-pick data-tooltip=&quot;Select file&quot; aria-label=&quot;Select file&quot;&gt;&lt;i class=&quot;ti ti-paperclip&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_600363a73de5f3a5">Rebuild</phrase>
<phrase data="auto_60512114c18fd5ed">&lt;span class=&quot;badge badge-amber&quot;&gt;needs reassembly&lt;/span&gt;</phrase>
<phrase data="auto_619742ac4a834c45">doc.</phrase>
<phrase data="auto_63b1fa390e91f75c">0 B</phrase>
<phrase data="auto_65b7f1673713a00c">d</phrase>
<phrase data="auto_66d207b9ebe9e4e3">&lt;div class=&quot;documents-term-status is-error&quot;&gt;&lt;i class=&quot;ti ti-alert-circle&quot;&gt;&lt;/i&gt;&lt;span&gt;Failed to load options&lt;/span&gt;&lt;/div&gt;</phrase>
<phrase data="auto_6702cc12ca25aff1">&lt;div class=&quot;empty-state&quot;&gt;There are no revisions yet. The first snapshot will appear after saving the document.&lt;/div&gt;</phrase>
<phrase data="auto_691245657a52bdce">&lt;div&gt;&lt;label class=&quot;documents-revision-check&quot; aria-label=&quot;Restore</phrase>
<phrase data="auto_6972375800d3e590">Clear the field?</phrase>
<phrase data="auto_698ace159126e252">Column 2</phrase>
<phrase data="auto_6b595786e068b5bc">Alias can be left empty</phrase>
<phrase data="auto_6baaabf049b42cca">e</phrase>
<phrase data="auto_6bee957a1f3ef55d">Length</phrase>
<phrase data="auto_6c190c23240564f5">f</phrase>
<phrase data="auto_6e408d3c9c3156bf">Delete &quot;</phrase>
<phrase data="auto_6e52ded051aa402d">Withdrawn</phrase>
<phrase data="auto_6f658622a374b8d6">&lt;/p&gt;&lt;/div&gt;&lt;button class=&quot;modal-close&quot; type=&quot;button&quot; data-relation-close aria-label=&quot;Close&quot;&gt;&lt;i class=&quot;ti ti-x&quot;&gt;&lt;/i&gt;&lt;/button&gt;&lt;/div&gt;</phrase>
<phrase data="auto_6fe63eee472a5a93">&lt;span class=&quot;badge badge-green&quot;&gt;relevant&lt;/span&gt;</phrase>
<phrase data="auto_707a724dce1fa0d4">URL copied</phrase>
<phrase data="auto_72c618d46424c93a">There are no saved values yet</phrase>
<phrase data="auto_736a75710565c2f2">[description]&quot; data-media-key=&quot;description&quot; rows=&quot;2&quot; placeholder=&quot;Description&quot;&gt;&lt;/textarea&gt;</phrase>
<phrase data="auto_75285ce97dc1dc97">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-value-down data-tooltip=&quot;Below&quot; aria-label=&quot;Below&quot;&gt;&lt;i class=&quot;ti ti-arrow-down&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_781e545c1063d5db">The integration will immediately lose access. It will be impossible to return this token.</phrase>
<phrase data="auto_7845e83aad6bffff">Errors found:</phrase>
<phrase data="auto_7a4f40b5797415a7">Category template: /</phrase>
<phrase data="auto_7a9b21737266b2d1">, errors:</phrase>
<phrase data="auto_7bd54e89985213b9">Template</phrase>
<phrase data="auto_7c3bb087be98e4e9">Failed to load statistics</phrase>
<phrase data="auto_7dcc70f455f9f122">API token revoked</phrase>
<phrase data="auto_7f09659cde925652">Find or add a tag</phrase>
<phrase data="auto_7f7328fc7d7316b8">n</phrase>
<phrase data="auto_80a2c74df6ffb08d">Open the desired folder and confirm your selection. All suitable files from it will be added to the field.</phrase>
<phrase data="auto_80c97478d2179d1c">&lt;div&gt;&lt;dt&gt;State&lt;/dt&gt;&lt;dd&gt;</phrase>
<phrase data="auto_83c8e08533872aff">&lt;div class=&quot;empty-state&quot;&gt;Loading...&lt;/div&gt;</phrase>
<phrase data="auto_84b96b359e9c61d3">The contents of the snapshot will appear after selecting a revision.</phrase>
<phrase data="auto_86ea33aef5e95d4f">Delete</phrase>
<phrase data="auto_8922542f90c16aee">Copied</phrase>
<phrase data="auto_8bf2ee2944fdd73f">optional</phrase>
<phrase data="auto_8c34e8bce7d519b3">The description will appear after filling out the meta description.</phrase>
<phrase data="auto_8d333d8b91196c4b">Documents</phrase>
<phrase data="auto_8d5d38d878604c11">The document has been restored</phrase>
<phrase data="auto_8e8ca8428150664e">g</phrase>
<phrase data="auto_8ff9429d66687894">No matches</phrase>
<phrase data="auto_919be7c1e27ba64c">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-value-up data-tooltip=&quot;Above&quot; aria-label=&quot;Above&quot;&gt;&lt;i class=&quot;ti ti-arrow-up&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_9541be7605dbe971">Permissions for one external integration.</phrase>
<phrase data="auto_96664ee8380d446c">The document has changed</phrase>
<phrase data="auto_9729bca62f6c558e">Request Error</phrase>
<phrase data="auto_97c557fb133f1f95">fields</phrase>
<phrase data="auto_98b2073ed1815f28">Clear</phrase>
<phrase data="auto_9b16ca539e602189">Revoke</phrase>
<phrase data="auto_9bf756bd3124a21d">Changes saved</phrase>
<phrase data="auto_9c4c0897f8ec6cf2">[title]&quot; data-media-key=&quot;title&quot; value=&quot;&quot; placeholder=&quot;Image title&quot;&gt;</phrase>
<phrase data="auto_9c7449bfd16b63a0">Remove</phrase>
<phrase data="auto_9caf4a95f812be6a">n</phrase>
<phrase data="auto_9d1e4bc060f5da40">Added from folder:</phrase>
<phrase data="auto_9d809f884b63cdec">Edit</phrase>
<phrase data="auto_9da304d709611b05">Failed to load revisions</phrase>
<phrase data="auto_9f0b990967c8a2ad">Meaning</phrase>
<phrase data="auto_9f3b3c3b78133faa">Select document</phrase>
<phrase data="auto_a15870f87e67e329">&lt;div&gt;&lt;dt&gt;Formed by&lt;/dt&gt;&lt;dd&gt;</phrase>
<phrase data="auto_a18928c7baf57b4e">Fields only</phrase>
<phrase data="auto_a2e9f606da125a72">b</phrase>
<phrase data="auto_a37cb7b62a6f1b65">The category does not have a path template: the document alias is used from the root of the site.</phrase>
<phrase data="auto_a45e66bc42d77b08">Failed to generate alias</phrase>
<phrase data="auto_a59e2c2429fe7a88">and</phrase>
<phrase data="auto_a5ecb551ebd318c2">Height,</phrase>
<phrase data="auto_a63e1ce2e55b8d0a">No image selected</phrase>
<phrase data="auto_a690b9dead4d8d8d">&lt;div&gt;&lt;dt&gt;Size&lt;/dt&gt;&lt;dd&gt;</phrase>
<phrase data="auto_a7774650638b586d">JSON snapshot rebuilt</phrase>
<phrase data="auto_a78e4bb6eb8c9ced">Document revisions</phrase>
<phrase data="auto_a8504d513adfb6f5">Heading</phrase>
<phrase data="auto_a90ed3357338f605">Loading...</phrase>
<phrase data="auto_ab30701c85ebee86">Remove document</phrase>
<phrase data="auto_ac3008315dc50b25">Add from this folder</phrase>
<phrase data="auto_ae008e22ec3a8627">All lines in this field will be removed from the document after saving.</phrase>
<phrase data="auto_af962c31c15d28d8">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm documents-drag-handle&quot; type=&quot;button&quot; data-doc-drag draggable=&quot;true&quot; data-tooltip=&quot;Drag&quot; aria-label=&quot;Drag&quot;&gt;&lt;i class=&quot;ti ti-grip-vertical&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_b0a419827426861b">Preset deleted</phrase>
<phrase data="auto_b0d827d33fc8eb3f">Pictures</phrase>
<phrase data="auto_b1c30d9335b4ffb7">/ + document alias. The date is taken from the publication.</phrase>
<phrase data="auto_b279170b217c1e48">&lt;div&gt;&lt;dt&gt;Fields&lt;/dt&gt;&lt;dd class=&quot;mono&quot;&gt;</phrase>
<phrase data="auto_b2ac54889c1e667e">Batch rebuild stopped</phrase>
<phrase data="auto_b36631e2d2874351">&lt;article class=&quot;documents-revision-field documents-revision-system-field&quot;&gt;&lt;div&gt;&lt;label class=&quot;documents-revision-check&quot; aria-label=&quot;Restore</phrase>
<phrase data="auto_b383b6f9be5e7ca2">Failed to get snapshot status</phrase>
<phrase data="auto_b40bbca36d2d172e">with</phrase>
<phrase data="auto_b5251271237d718b">These files are already in the field</phrase>
<phrase data="auto_b92a4704544f84aa">The pictures will be sequentially reassembled to</phrase>
<phrase data="auto_b9917824e609b5b3">pictures</phrase>
<phrase data="auto_baa96cc9f67a7eb8">yu</phrase>
<phrase data="auto_be1aff65661f854c">[name]&quot; data-media-key=&quot;name&quot; value=&quot;&quot; placeholder=&quot;File name&quot;&gt;</phrase>
<phrase data="auto_be4a59e132257571">Revoke an API token?</phrase>
<phrase data="auto_be7ec45710267c5c">&lt;section class=&quot;documents-revision-content&quot;&gt;&lt;div class=&quot;documents-revision-subhead&quot;&gt;&lt;i class=&quot;ti ti-forms&quot;&gt;&lt;/i&gt;&lt;b&gt;Category fields&lt;/b&gt;&lt;span&gt;</phrase>
<phrase data="auto_bea15c7bbd9e167c">e</phrase>
<phrase data="auto_beed168817322eeb">from</phrase>
<phrase data="auto_bf6379458cca6658">m</phrase>
<phrase data="auto_c13875d6c76e5c2e">w</phrase>
<phrase data="auto_c2b70c3bb20678a7">Failed to apply filters</phrase>
<phrase data="auto_c52083faa73d3829">Field</phrase>
<phrase data="auto_c5eb26618a7ede98">Read only</phrase>
<phrase data="auto_c7fca87d8dd9b0e6">Click to find document</phrase>
<phrase data="auto_c9d8ea6476aa730d">Add &quot;</phrase>
<phrase data="auto_cccd51a8c6aa88c1">&lt;div class=&quot;documents-term-status&quot;&gt;&lt;i class=&quot;ti ti-loader-2&quot;&gt;&lt;/i&gt;&lt;span&gt;Looking for matches...&lt;/span&gt;&lt;/div&gt;</phrase>
<phrase data="auto_ccfe048a5551ed0f">preset</phrase>
<phrase data="auto_cdd3b42f2a48df2f">Delete preset</phrase>
<phrase data="auto_ceebfda5155965db">Failed to generate short alias</phrase>
<phrase data="auto_cf0fc49836b59906">can be left blank</phrase>
<phrase data="auto_cf412de9896e972b">Select at least one resolution</phrase>
<phrase data="auto_d0888e0521387c25">Width,</phrase>
<phrase data="auto_d164037dc0f1bd06">Delete revision</phrase>
<phrase data="auto_d2264bee0979ddf6">&lt;div&gt;&lt;dt&gt;Status&lt;/dt&gt;&lt;dd&gt;Loading...&lt;/dd&gt;&lt;/div&gt;</phrase>
<phrase data="auto_d6951b2c8c477f98">All elements of this field will be removed from the document after saving.</phrase>
<phrase data="auto_d6b08d92a7694cd4">Weight,</phrase>
<phrase data="auto_d77df87cd6098c73">History of field values</phrase>
<phrase data="auto_d7907f4d435df378">Additionally</phrase>
<phrase data="auto_d944cde8b15c4d8b">at</phrase>
<phrase data="auto_d9c83b31f5cb2376">Failed to check short alias</phrase>
<phrase data="auto_da97cf5fe3fc4c42">Checking...</phrase>
<phrase data="auto_db4d819570e77e56">draft</phrase>
<phrase data="auto_dcedec8acc4af7ca">Assignment document</phrase>
<phrase data="auto_dd0ec9a1c7140562">Add files from a folder</phrase>
<phrase data="auto_defc150c8003aa92">Revisions deleted</phrase>
<phrase data="auto_df5763ef792cf2f4">cm</phrase>
<phrase data="auto_e01aed78c667a5aa">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm documents-value-remove&quot; type=&quot;button&quot; data-document-value-remove data-tooltip=&quot;Delete&quot; aria-label=&quot;Delete&quot;&gt;&lt;i class=&quot;ti ti-trash&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_e4cedbc81a3c0d4e">ъ</phrase>
<phrase data="auto_e576260a6a03f359">t</phrase>
<phrase data="auto_e76f1ce35ab97dfb">&lt;/span&gt;&lt;label class=&quot;documents-revision-group-check&quot;&gt;&lt;input type=&quot;checkbox&quot; data-document-revision-group=&quot;document&quot; checked&gt;&lt;span&gt;All&lt;/span&gt;&lt;/label&gt;&lt;/div&gt;</phrase>
<phrase data="auto_e83e7fbf2312ab9e">The document has already been changed. Refresh the page.</phrase>
<phrase data="auto_e94209605c9c0a4e">&lt;button class=&quot;documents-media-thumb&quot; type=&quot;button&quot; data-document-media-pick aria-label=&quot;Select file&quot;&gt;&lt;span&gt;&lt;i class=&quot;ti ti-</phrase>
<phrase data="auto_ec6be72560e6078a">The story is still empty</phrase>
<phrase data="auto_eeb60e75b96656e7">Incorrect answer</phrase>
<phrase data="auto_f0b532724126bbe8">Clear all field elements?</phrase>
<phrase data="auto_f26e5c0c72082089">Find or add a keyword</phrase>
<phrase data="auto_f3d123e46297531d">in</phrase>
<phrase data="auto_f42c85cd57ba4aa0">Stay</phrase>
<phrase data="auto_f6069600bcb07eea">&lt;div class=&quot;modal-footer&quot;&gt;&lt;div class=&quot;mf-left documents-picker-count&quot; data-relation-count&gt;&lt;/div&gt;&lt;button class=&quot;btn btn-ghost&quot; type=&quot;button&quot; data-relation-close&gt;Close&lt;/button&gt;&lt;/div&gt;</phrase>
<phrase data="auto_f61de80bf0ca15a4">No document selected</phrase>
<phrase data="auto_f7286439b109e428">New redirect</phrase>
<phrase data="auto_f7806fce80e51b40">Old URL</phrase>
<phrase data="auto_f914c65278294e4a">&lt;div class=&quot;modal-header&quot;&gt;&lt;span class=&quot;dialog-icon info&quot;&gt;&lt;i class=&quot;ti ti-file-search&quot;&gt;&lt;/i&gt;&lt;/span&gt;&lt;div style=&quot;flex:1&quot;&gt;&lt;h3&gt;Select document&lt;/h3&gt;&lt;p class=&quot;text-secondary&quot; style=&quot;margin-top:4px&quot;&gt;</phrase>
<phrase data="auto_fa9f0524f3a0531e">Add a tag</phrase>
<phrase data="auto_fc8b3939cc9dc467">The path will be written in the document field.</phrase>
<phrase data="auto_fda74d41e3311f87">A newer version is already saved on the server. Refresh the page, check the changes, and save the document again.</phrase>
<phrase data="auto_ffec67789fb1fcdc">sch</phrase>
<phrase data="runtime_05d4ae78b6f1bfee">n</phrase>
<phrase data="runtime_0918b4ba92684eaa">Title</phrase>
<phrase data="runtime_0940401102b8a3fe">t</phrase>
<phrase data="runtime_11cef8141437f8a6">ъ</phrase>
<phrase data="runtime_15f2ccabeb425c29">th</phrase>
<phrase data="runtime_1cc10a3947f7c245">d</phrase>
<phrase data="runtime_1d51ebe0fc45e38e">Fields only</phrase>
<phrase data="runtime_259f56cb715ba3a3">e</phrase>
<phrase data="runtime_3051067c6324c63f">h</phrase>
<phrase data="runtime_30fbc377ae9122ed">e</phrase>
<phrase data="runtime_3164696756227147">Note added</phrase>
<phrase data="runtime_34c6fbb62da8ce5c">n</phrase>
<phrase data="runtime_37d43a57a463add5">yu</phrase>
<phrase data="runtime_40831c5d98a57dfc">h</phrase>
<phrase data="runtime_446cdc8e3c0d7efc">s</phrase>
<phrase data="runtime_46b0d3855548054a">JSON snapshot rebuilt</phrase>
<phrase data="runtime_4f60bd4e24e7dc7f">w</phrase>
<phrase data="runtime_50338c005c41fb4e">o</phrase>
<phrase data="runtime_5225a95b111d47bf">to</phrase>
<phrase data="runtime_53b10ee4405f56d6">not created yet</phrase>
<phrase data="runtime_557f4cd2c6d3c150">cm</phrase>
<phrase data="runtime_61095f9d62dfe291">The document has been restored</phrase>
<phrase data="runtime_6228f1454b196ffd">API token revoked</phrase>
<phrase data="runtime_65fd24f1cb37fec4">Draft</phrase>
<phrase data="runtime_72a81659de042887">Revisions deleted</phrase>
<phrase data="runtime_734dbc2b54ab6c95">r</phrase>
<phrase data="runtime_754309a41e0df98a">f</phrase>
<phrase data="runtime_7592b7ec4eba966a">ts</phrase>
<phrase data="runtime_7b0ff8a2e5fdd66d">m</phrase>
<phrase data="runtime_823c4eb3e895adc9">a</phrase>
<phrase data="runtime_85011fce4419ee63">Field #</phrase>
<phrase data="runtime_894f97c52af9e5ac">Note deleted</phrase>
<phrase data="runtime_9aa032336a8e122c">b</phrase>
<phrase data="runtime_9b21f5cf014c9efb">I</phrase>
<phrase data="runtime_9b6276c9a4ac5507">Document saved</phrase>
<phrase data="runtime_a1f5d39a12e8f6be">with</phrase>
<phrase data="runtime_ab1f4be7e5d6fd8b">g</phrase>
<phrase data="runtime_ae51ade91d2b85b7">Revision deleted</phrase>
<phrase data="runtime_b8012cb642c887a0">in</phrase>
<phrase data="runtime_b95d78565f154f82">b</phrase>
<phrase data="runtime_bb69a96efca3303e">x</phrase>
<phrase data="runtime_bbb369f210d2969f">sch</phrase>
<phrase data="runtime_beee80c256b458da">at</phrase>
<phrase data="runtime_bf0d18ef68a8a34e">can be left blank</phrase>
<phrase data="runtime_c08fb476eca49399">kg</phrase>
<phrase data="runtime_c5412ba2cc2fc45a">Creation preset saved</phrase>
<phrase data="runtime_c78364c5d0f27706">B</phrase>
<phrase data="runtime_cdd02bd8b5684812">Redirect removed</phrase>
<phrase data="runtime_d92fff560eb5495e">l</phrase>
<phrase data="runtime_dd48804aa38b03e1">Preset deleted</phrase>
<phrase data="runtime_dd591284d827e17f">f</phrase>
<phrase data="runtime_e1c88af54b9fced2">Document #</phrase>
<phrase data="runtime_e35c8bb151d3530d">Redirect saved</phrase>
<phrase data="runtime_e93c3d6287d39cc3">API token created</phrase>
<phrase data="runtime_ee71f8405f87d786">uh</phrase>
<phrase data="runtime_eead37caf818d0fa">and</phrase>
<phrase data="runtime_f4019994d4e75a2a">after saving</phrase>
<phrase data="runtime_f9a506077fa0d961">New</phrase>
<phrase data="runtime_fff8b34d92dab340">Template</phrase>
</language>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="documents_catalog_empty">No sections selected.</phrase>
<phrase data="documents_catalog_add">Add section</phrase>
<phrase data="documents_catalog_search">Find section</phrase>
<phrase data="documents_catalog_remove">Remove section</phrase>
<phrase data="documents_catalog_document_prefix">document #</phrase>
<phrase data="documents_catalog_hidden">hidden</phrase>
</language>
@@ -1,47 +1,290 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="auto_003cc12783448df3">х</phrase>
<phrase data="auto_0156bf1b8aac8565">Добавить ещё…</phrase>
<phrase data="auto_02b4ebb5dc50bca5">В поле-ссылку будет записан адрес документа.</phrase>
<phrase data="auto_02fdd4b8923613d6">До 10 символов, необязательно.</phrase>
<phrase data="auto_04c0389bc0db2353">Токен скопирован</phrase>
<phrase data="auto_080887ebc7bb5402">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm documents-media-remove&quot; type=&quot;button&quot; data-document-media-remove data-tooltip=&quot;Удалить&quot; aria-label=&quot;Удалить&quot;&gt;&lt;i class=&quot;ti ti-trash&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_0957de938411c15b">поле</phrase>
<phrase data="auto_0af969e08c220502">Очистить все строки поля?</phrase>
<phrase data="auto_0db49b55557f2bde">Параметр</phrase>
<phrase data="auto_0e7fbe3404b67fe3">Пересобрать JSON-снимки?</phrase>
<phrase data="auto_0f9ccee053fe3b6b">Новый</phrase>
<phrase data="auto_0ff730d95f9c5bc3">нажмите, чтобы выбрать</phrase>
<phrase data="auto_122f4893d3d0d802">Не удалось проверить alias</phrase>
<phrase data="auto_1291e1971ba28ee6">API-токен создан</phrase>
<phrase data="auto_12bfabff3d7dc01c">Добавить ключевое слово</phrase>
<phrase data="auto_18337ef195f7d88d">&lt;div class=&quot;modal-body&quot;&gt;&lt;div class=&quot;input-wrap documents-relation-search&quot;&gt;&lt;i class=&quot;ti ti-search&quot;&gt;&lt;/i&gt;&lt;input class=&quot;input&quot; type=&quot;search&quot; placeholder=&quot;ID, название или alias&quot; data-relation-search&gt;&lt;/div&gt;&lt;div class=&quot;documents-picker-status&quot; data-relation-status&gt;Загрузка...&lt;/div&gt;&lt;div class=&quot;documents-relation-list&quot; data-relation-list&gt;&lt;/div&gt;&lt;/div&gt;</phrase>
<phrase data="auto_1894f01f2cfa4702">JSON-снимок</phrase>
<phrase data="auto_1a7725e697463fd8">к</phrase>
<phrase data="auto_1af72c14888c4574">Пресет создания</phrase>
<phrase data="auto_1bc16af35fa47103">э</phrase>
<phrase data="auto_1d00a5603a94c17d">ID тизера / ссылка</phrase>
<phrase data="auto_1d402bc8ffda29cd">Внутренняя ошибка сервера. Обновите страницу и повторите попытку.</phrase>
<phrase data="auto_1f864100c5c97f02">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-down data-tooltip=&quot;Ниже&quot; aria-label=&quot;Ниже&quot;&gt;&lt;i class=&quot;ti ti-arrow-down&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_22dc89ab84f07324">&quot;&gt;&lt;button class=&quot;btn btn-secondary btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-pick data-tooltip=&quot;Выбрать файл&quot; aria-label=&quot;Выбрать файл&quot;&gt;&lt;i class=&quot;ti ti-</phrase>
<phrase data="auto_230b5877294e9726">ц</phrase>
<phrase data="auto_23289c89f8c038a0">В поле будет записан ID документа.</phrase>
<phrase data="auto_242f64ddfe84899b">после сохранения</phrase>
<phrase data="auto_25a9845975119103">Пресет создания сохранён</phrase>
<phrase data="auto_25dc101484dbd55c">Не удалось загрузить список</phrase>
<phrase data="auto_285cc4001478d568">ч</phrase>
<phrase data="auto_28c993e4935524d8">з</phrase>
<phrase data="auto_28fb6167ec0a79fe">&lt;section class=&quot;documents-revision-system&quot;&gt;&lt;div class=&quot;documents-revision-subhead&quot;&gt;&lt;i class=&quot;ti ti-settings&quot;&gt;&lt;/i&gt;&lt;b&gt;Основные настройки&lt;/b&gt;&lt;span&gt;</phrase>
<phrase data="auto_292688e9915e161b">[link]&quot; data-media-key=&quot;link&quot; value=&quot;&quot; placeholder=&quot;Ссылка или документ&quot; data-document-media-url data-document-picker-type=&quot;all&quot;&gt;</phrase>
<phrase data="auto_29f3b29df0d0e54f">Восстановить</phrase>
<phrase data="auto_2a9790fd6fab2922">&lt;button class=&quot;btn btn-secondary btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-doc-pick data-tooltip=&quot;Выбрать документ&quot; aria-label=&quot;Выбрать документ&quot;&gt;&lt;i class=&quot;ti ti-file-search&quot;&gt;&lt;/i&gt;&lt;/button&gt;&lt;/div&gt;</phrase>
<phrase data="auto_2ba58b00b4c52021">&lt;/span&gt;&lt;label class=&quot;documents-revision-group-check&quot;&gt;&lt;input type=&quot;checkbox&quot; data-document-revision-group=&quot;field&quot; checked&gt;&lt;span&gt;Все&lt;/span&gt;&lt;/label&gt;&lt;/div&gt;</phrase>
<phrase data="auto_2c501dc73177148a">ID, название или alias</phrase>
<phrase data="auto_2e31dae95f35e3b8">Выбрать файл</phrase>
<phrase data="auto_2ec20105a2757791">Колонка 3</phrase>
<phrase data="auto_2f1256a9610f7d64">Редирект сохранён</phrase>
<phrase data="auto_2ff1fe42207df255">Секрет готов к копированию.</phrase>
<phrase data="auto_301361625f901825">Сервер вернул некорректный ответ.</phrase>
<phrase data="auto_30278b05221dd957">Обновить страницу</phrase>
<phrase data="auto_32b74a3c47908c1e">Без названия</phrase>
<phrase data="auto_33392e5848b4a8eb">JSON-снимки пересобраны:</phrase>
<phrase data="auto_336d12a4f96485a3">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-up data-tooltip=&quot;Выше&quot; aria-label=&quot;Выше&quot;&gt;&lt;i class=&quot;ti ti-arrow-up&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_359282f9ef75e654">ID документа</phrase>
<phrase data="auto_35de32df9d942d9a">Заметка добавлена</phrase>
<phrase data="auto_3697bef0b3fc93f5">новый</phrase>
<phrase data="auto_3812a04b189ec1fd">новое</phrase>
<phrase data="auto_39704f311a86cd23">Черновик</phrase>
<phrase data="auto_3de49828e86a4016">Название</phrase>
<phrase data="auto_3e096d0063299173">Ревизия удалена</phrase>
<phrase data="auto_3e25fbc59741a10a">Заголовок документа</phrase>
<phrase data="auto_3e670a1ec591883c">Файлы загружены</phrase>
<phrase data="auto_3eb77d3c13c51f92">Выберите ревизию</phrase>
<phrase data="auto_3f039532d65bb5e3">Пересобрать JSON</phrase>
<phrase data="auto_4044fbc87708b9ad">Очистить подневную статистику?</phrase>
<phrase data="auto_40785832c27c3a51">Колонка 1</phrase>
<phrase data="auto_42ba924b3732dc07">поля</phrase>
<phrase data="auto_433e4b40a403cdb0">й</phrase>
<phrase data="auto_447d642f14fb2c8c">В папке нет подходящих файлов</phrase>
<phrase data="auto_46551a5e6e43e854">Статистика очищена</phrase>
<phrase data="auto_48be85b47e187f3c">ещё не создан</phrase>
<phrase data="auto_4a05d8619dedc462">Копировать</phrase>
<phrase data="auto_4b4c88ed13b23b15">Редирект удалён</phrase>
<phrase data="auto_4d60d98f499d1580">Ревизии</phrase>
<phrase data="auto_4f566fb5e3df4276">я</phrase>
<phrase data="auto_516cbe2af24391af">л</phrase>
<phrase data="auto_51b4eac98af84251">а</phrase>
<phrase data="auto_51f784cde1916288">ь</phrase>
<phrase data="auto_522e27d7402089bc">Документ сохранён</phrase>
<phrase data="auto_53e908b396e3a6b3">&lt;span class=&quot;badge badge-gray&quot;&gt;не создан&lt;/span&gt;</phrase>
<phrase data="auto_54a8375d14dfdab5">Не удалось прочитать папку</phrase>
<phrase data="auto_56ac0cff0f4a358f">Заметка удалена</phrase>
<phrase data="auto_576895a59897535b">Файл не выбран</phrase>
<phrase data="auto_577021b5ac1fd915">ф</phrase>
<phrase data="auto_57afad1302f9645e">о</phrase>
<phrase data="auto_588c8feeb973dcb1">&lt;div&gt;&lt;dt&gt;Файл&lt;/dt&gt;&lt;dd class=&quot;mono&quot;&gt;</phrase>
<phrase data="auto_59ddef8a55503c83">кг</phrase>
<phrase data="auto_5a3b4bcf55400711">р</phrase>
<phrase data="auto_5bd0d9fe0580526f">. Окно нужно оставить открытым до завершения.</phrase>
<phrase data="auto_5c019fa5d03ca5cf">в рубрике</phrase>
<phrase data="auto_5e6c96299a2f11e9">ы</phrase>
<phrase data="auto_5e6d6b07b27b7a7c">Все строки view_count будут удалены. Общие счётчики документов останутся без изменений.</phrase>
<phrase data="auto_5f9eaa37c4e2ccff">&lt;button class=&quot;btn btn-secondary btn-icon btn-sm&quot; type=&quot;button&quot; data-document-media-pick data-tooltip=&quot;Выбрать файл&quot; aria-label=&quot;Выбрать файл&quot;&gt;&lt;i class=&quot;ti ti-paperclip&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_600363a73de5f3a5">Пересобрать</phrase>
<phrase data="auto_60512114c18fd5ed">&lt;span class=&quot;badge badge-amber&quot;&gt;нужна пересборка&lt;/span&gt;</phrase>
<phrase data="auto_619742ac4a834c45">док.</phrase>
<phrase data="auto_63b1fa390e91f75c">0 Б</phrase>
<phrase data="auto_65b7f1673713a00c">д</phrase>
<phrase data="auto_66d207b9ebe9e4e3">&lt;div class=&quot;documents-term-status is-error&quot;&gt;&lt;i class=&quot;ti ti-alert-circle&quot;&gt;&lt;/i&gt;&lt;span&gt;Не удалось загрузить варианты&lt;/span&gt;&lt;/div&gt;</phrase>
<phrase data="auto_6702cc12ca25aff1">&lt;div class=&quot;empty-state&quot;&gt;Ревизий пока нет. Первый снимок появится после сохранения документа.&lt;/div&gt;</phrase>
<phrase data="auto_691245657a52bdce">&lt;div&gt;&lt;label class=&quot;documents-revision-check&quot; aria-label=&quot;Восстановить</phrase>
<phrase data="auto_6972375800d3e590">Очистить поле?</phrase>
<phrase data="auto_698ace159126e252">Колонка 2</phrase>
<phrase data="auto_6b595786e068b5bc">Alias можно оставить пустым</phrase>
<phrase data="auto_6baaabf049b42cca">ё</phrase>
<phrase data="auto_6bee957a1f3ef55d">Длина,</phrase>
<phrase data="auto_6c190c23240564f5">ж</phrase>
<phrase data="auto_6e408d3c9c3156bf">Удалить «</phrase>
<phrase data="auto_6e52ded051aa402d">Отозван</phrase>
<phrase data="auto_6f658622a374b8d6">&lt;/p&gt;&lt;/div&gt;&lt;button class=&quot;modal-close&quot; type=&quot;button&quot; data-relation-close aria-label=&quot;Закрыть&quot;&gt;&lt;i class=&quot;ti ti-x&quot;&gt;&lt;/i&gt;&lt;/button&gt;&lt;/div&gt;</phrase>
<phrase data="auto_6fe63eee472a5a93">&lt;span class=&quot;badge badge-green&quot;&gt;актуален&lt;/span&gt;</phrase>
<phrase data="auto_707a724dce1fa0d4">URL скопирован</phrase>
<phrase data="auto_72c618d46424c93a">Сохранённых значений пока нет</phrase>
<phrase data="auto_736a75710565c2f2">[description]&quot; data-media-key=&quot;description&quot; rows=&quot;2&quot; placeholder=&quot;Описание&quot;&gt;&lt;/textarea&gt;</phrase>
<phrase data="auto_75285ce97dc1dc97">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-value-down data-tooltip=&quot;Ниже&quot; aria-label=&quot;Ниже&quot;&gt;&lt;i class=&quot;ti ti-arrow-down&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_781e545c1063d5db">Интеграция сразу потеряет доступ. Вернуть этот токен будет невозможно.</phrase>
<phrase data="auto_7845e83aad6bffff">Найдено ошибок:</phrase>
<phrase data="auto_7a4f40b5797415a7">Шаблон рубрики: /</phrase>
<phrase data="auto_7a9b21737266b2d1">, ошибок:</phrase>
<phrase data="auto_7bd54e89985213b9">Шаблон</phrase>
<phrase data="auto_7c3bb087be98e4e9">Не удалось загрузить статистику</phrase>
<phrase data="auto_7dcc70f455f9f122">API-токен отозван</phrase>
<phrase data="auto_7f09659cde925652">Найти или добавить тег</phrase>
<phrase data="auto_7f7328fc7d7316b8">н</phrase>
<phrase data="auto_80a2c74df6ffb08d">Откройте нужную папку и подтвердите выбор. В поле добавятся все подходящие файлы из неё.</phrase>
<phrase data="auto_80c97478d2179d1c">&lt;div&gt;&lt;dt&gt;Состояние&lt;/dt&gt;&lt;dd&gt;</phrase>
<phrase data="auto_83c8e08533872aff">&lt;div class=&quot;empty-state&quot;&gt;Загрузка...&lt;/div&gt;</phrase>
<phrase data="auto_84b96b359e9c61d3">Содержимое снимка появится после выбора ревизии.</phrase>
<phrase data="auto_86ea33aef5e95d4f">Удалить</phrase>
<phrase data="auto_8922542f90c16aee">Скопировано</phrase>
<phrase data="auto_8bf2ee2944fdd73f">необязательно</phrase>
<phrase data="auto_8c34e8bce7d519b3">Описание появится после заполнения meta description.</phrase>
<phrase data="auto_8d333d8b91196c4b">Документов</phrase>
<phrase data="auto_8d5d38d878604c11">Документ восстановлен</phrase>
<phrase data="auto_8e8ca8428150664e">г</phrase>
<phrase data="auto_8ff9429d66687894">Совпадений нет</phrase>
<phrase data="auto_919be7c1e27ba64c">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm&quot; type=&quot;button&quot; data-document-value-up data-tooltip=&quot;Выше&quot; aria-label=&quot;Выше&quot;&gt;&lt;i class=&quot;ti ti-arrow-up&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_9541be7605dbe971">Права доступа для одной внешней интеграции.</phrase>
<phrase data="auto_96664ee8380d446c">Документ изменился</phrase>
<phrase data="auto_9729bca62f6c558e">Ошибка запроса</phrase>
<phrase data="auto_97c557fb133f1f95">полей</phrase>
<phrase data="auto_98b2073ed1815f28">Очистить</phrase>
<phrase data="auto_9b16ca539e602189">Отозвать</phrase>
<phrase data="auto_9bf756bd3124a21d">Изменения сохранены</phrase>
<phrase data="auto_9c4c0897f8ec6cf2">[title]&quot; data-media-key=&quot;title&quot; value=&quot;&quot; placeholder=&quot;Заголовок изображения&quot;&gt;</phrase>
<phrase data="auto_9c7449bfd16b63a0">Убрать</phrase>
<phrase data="auto_9caf4a95f812be6a">п</phrase>
<phrase data="auto_9d1e4bc060f5da40">Добавлено из папки:</phrase>
<phrase data="auto_9d809f884b63cdec">Изменить</phrase>
<phrase data="auto_9da304d709611b05">Не удалось загрузить ревизии</phrase>
<phrase data="auto_9f0b990967c8a2ad">Значение</phrase>
<phrase data="auto_9f3b3c3b78133faa">Выбрать документ</phrase>
<phrase data="auto_a15870f87e67e329">&lt;div&gt;&lt;dt&gt;Сформирован&lt;/dt&gt;&lt;dd&gt;</phrase>
<phrase data="auto_a18928c7baf57b4e">Только поля</phrase>
<phrase data="auto_a2e9f606da125a72">б</phrase>
<phrase data="auto_a37cb7b62a6f1b65">У рубрики нет шаблона пути: alias документа используется от корня сайта.</phrase>
<phrase data="auto_a45e66bc42d77b08">Не удалось сгенерировать alias</phrase>
<phrase data="auto_a59e2c2429fe7a88">и</phrase>
<phrase data="auto_a5ecb551ebd318c2">Высота,</phrase>
<phrase data="auto_a63e1ce2e55b8d0a">Изображение не выбрано</phrase>
<phrase data="auto_a690b9dead4d8d8d">&lt;div&gt;&lt;dt&gt;Размер&lt;/dt&gt;&lt;dd&gt;</phrase>
<phrase data="auto_a7774650638b586d">JSON-снимок пересобран</phrase>
<phrase data="auto_a78e4bb6eb8c9ced">Ревизии документа</phrase>
<phrase data="auto_a8504d513adfb6f5">Заголовок</phrase>
<phrase data="auto_a90ed3357338f605">Загрузка...</phrase>
<phrase data="auto_ab30701c85ebee86">Убрать документ</phrase>
<phrase data="auto_ac3008315dc50b25">Добавить из этой папки</phrase>
<phrase data="auto_ae008e22ec3a8627">Все строки этого поля будут удалены из документа после сохранения.</phrase>
<phrase data="auto_af962c31c15d28d8">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm documents-drag-handle&quot; type=&quot;button&quot; data-doc-drag draggable=&quot;true&quot; data-tooltip=&quot;Перетащить&quot; aria-label=&quot;Перетащить&quot;&gt;&lt;i class=&quot;ti ti-grip-vertical&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_b0a419827426861b">Пресет удалён</phrase>
<phrase data="auto_b0d827d33fc8eb3f">Снимки</phrase>
<phrase data="auto_b1c30d9335b4ffb7">/ + alias документа. Дата берётся из публикации.</phrase>
<phrase data="auto_b279170b217c1e48">&lt;div&gt;&lt;dt&gt;Поля&lt;/dt&gt;&lt;dd class=&quot;mono&quot;&gt;</phrase>
<phrase data="auto_b2ac54889c1e667e">Пакетная пересборка остановлена</phrase>
<phrase data="auto_b36631e2d2874351">&lt;article class=&quot;documents-revision-field documents-revision-system-field&quot;&gt;&lt;div&gt;&lt;label class=&quot;documents-revision-check&quot; aria-label=&quot;Восстановить</phrase>
<phrase data="auto_b383b6f9be5e7ca2">Не удалось получить статус снимка</phrase>
<phrase data="auto_b40bbca36d2d172e">с</phrase>
<phrase data="auto_b5251271237d718b">Эти файлы уже есть в поле</phrase>
<phrase data="auto_b92a4704544f84aa">Снимки будут последовательно пересобраны для</phrase>
<phrase data="auto_b9917824e609b5b3">снимков</phrase>
<phrase data="auto_baa96cc9f67a7eb8">ю</phrase>
<phrase data="auto_be1aff65661f854c">[name]&quot; data-media-key=&quot;name&quot; value=&quot;&quot; placeholder=&quot;Название файла&quot;&gt;</phrase>
<phrase data="auto_be4a59e132257571">Отозвать API-токен?</phrase>
<phrase data="auto_be7ec45710267c5c">&lt;section class=&quot;documents-revision-content&quot;&gt;&lt;div class=&quot;documents-revision-subhead&quot;&gt;&lt;i class=&quot;ti ti-forms&quot;&gt;&lt;/i&gt;&lt;b&gt;Поля рубрики&lt;/b&gt;&lt;span&gt;</phrase>
<phrase data="auto_bea15c7bbd9e167c">е</phrase>
<phrase data="auto_beed168817322eeb">из</phrase>
<phrase data="auto_bf6379458cca6658">м</phrase>
<phrase data="auto_c13875d6c76e5c2e">ш</phrase>
<phrase data="auto_c2b70c3bb20678a7">Не удалось применить фильтры</phrase>
<phrase data="auto_c52083faa73d3829">Поле</phrase>
<phrase data="auto_c5eb26618a7ede98">Только чтение</phrase>
<phrase data="auto_c7fca87d8dd9b0e6">Нажмите, чтобы найти документ</phrase>
<phrase data="auto_c9d8ea6476aa730d">Добавить «</phrase>
<phrase data="auto_cccd51a8c6aa88c1">&lt;div class=&quot;documents-term-status&quot;&gt;&lt;i class=&quot;ti ti-loader-2&quot;&gt;&lt;/i&gt;&lt;span&gt;Ищем совпадения…&lt;/span&gt;&lt;/div&gt;</phrase>
<phrase data="auto_ccfe048a5551ed0f">пресет</phrase>
<phrase data="auto_cdd3b42f2a48df2f">Удалить пресет</phrase>
<phrase data="auto_ceebfda5155965db">Не удалось сгенерировать короткий алиас</phrase>
<phrase data="auto_cf0fc49836b59906">можно оставить пустым</phrase>
<phrase data="auto_cf412de9896e972b">Выберите хотя бы одно разрешение</phrase>
<phrase data="auto_d0888e0521387c25">Ширина,</phrase>
<phrase data="auto_d164037dc0f1bd06">Удалить ревизию</phrase>
<phrase data="auto_d2264bee0979ddf6">&lt;div&gt;&lt;dt&gt;Состояние&lt;/dt&gt;&lt;dd&gt;Загрузка...&lt;/dd&gt;&lt;/div&gt;</phrase>
<phrase data="auto_d6951b2c8c477f98">Все элементы этого поля будут удалены из документа после сохранения.</phrase>
<phrase data="auto_d6b08d92a7694cd4">Вес,</phrase>
<phrase data="auto_d77df87cd6098c73">История значений полей</phrase>
<phrase data="auto_d7907f4d435df378">Дополнительно</phrase>
<phrase data="auto_d944cde8b15c4d8b">у</phrase>
<phrase data="auto_d9c83b31f5cb2376">Не удалось проверить короткий alias</phrase>
<phrase data="auto_da97cf5fe3fc4c42">Проверка...</phrase>
<phrase data="auto_db4d819570e77e56">черновик</phrase>
<phrase data="auto_dcedec8acc4af7ca">Документ назначения</phrase>
<phrase data="auto_dd0ec9a1c7140562">Добавить файлы из папки</phrase>
<phrase data="auto_defc150c8003aa92">Ревизии удалены</phrase>
<phrase data="auto_df5763ef792cf2f4">см</phrase>
<phrase data="auto_e01aed78c667a5aa">&lt;button class=&quot;btn btn-ghost btn-icon btn-sm documents-value-remove&quot; type=&quot;button&quot; data-document-value-remove data-tooltip=&quot;Удалить&quot; aria-label=&quot;Удалить&quot;&gt;&lt;i class=&quot;ti ti-trash&quot;&gt;&lt;/i&gt;&lt;/button&gt;</phrase>
<phrase data="auto_e4cedbc81a3c0d4e">ъ</phrase>
<phrase data="auto_e576260a6a03f359">т</phrase>
<phrase data="auto_e76f1ce35ab97dfb">&lt;/span&gt;&lt;label class=&quot;documents-revision-group-check&quot;&gt;&lt;input type=&quot;checkbox&quot; data-document-revision-group=&quot;document&quot; checked&gt;&lt;span&gt;Все&lt;/span&gt;&lt;/label&gt;&lt;/div&gt;</phrase>
<phrase data="auto_e83e7fbf2312ab9e">Документ уже изменён. Обновите страницу.</phrase>
<phrase data="auto_e94209605c9c0a4e">&lt;button class=&quot;documents-media-thumb&quot; type=&quot;button&quot; data-document-media-pick aria-label=&quot;Выбрать файл&quot;&gt;&lt;span&gt;&lt;i class=&quot;ti ti-</phrase>
<phrase data="auto_ec6be72560e6078a">История пока пустая</phrase>
<phrase data="auto_eeb60e75b96656e7">Некорректный ответ</phrase>
<phrase data="auto_f0b532724126bbe8">Очистить все элементы поля?</phrase>
<phrase data="auto_f26e5c0c72082089">Найти или добавить ключевое слово</phrase>
<phrase data="auto_f3d123e46297531d">в</phrase>
<phrase data="auto_f42c85cd57ba4aa0">Остаться</phrase>
<phrase data="auto_f6069600bcb07eea">&lt;div class=&quot;modal-footer&quot;&gt;&lt;div class=&quot;mf-left documents-picker-count&quot; data-relation-count&gt;&lt;/div&gt;&lt;button class=&quot;btn btn-ghost&quot; type=&quot;button&quot; data-relation-close&gt;Закрыть&lt;/button&gt;&lt;/div&gt;</phrase>
<phrase data="auto_f61de80bf0ca15a4">Документ не выбран</phrase>
<phrase data="auto_f7286439b109e428">Новый редирект</phrase>
<phrase data="auto_f7806fce80e51b40">Старый URL</phrase>
<phrase data="auto_f914c65278294e4a">&lt;div class=&quot;modal-header&quot;&gt;&lt;span class=&quot;dialog-icon info&quot;&gt;&lt;i class=&quot;ti ti-file-search&quot;&gt;&lt;/i&gt;&lt;/span&gt;&lt;div style=&quot;flex:1&quot;&gt;&lt;h3&gt;Выбрать документ&lt;/h3&gt;&lt;p class=&quot;text-secondary&quot; style=&quot;margin-top:4px&quot;&gt;</phrase>
<phrase data="auto_fa9f0524f3a0531e">Добавить тег</phrase>
<phrase data="auto_fc8b3939cc9dc467">Путь будет записан в поле документа.</phrase>
<phrase data="auto_fda74d41e3311f87">На сервере уже сохранена более новая версия. Обновите страницу, проверьте изменения и сохраните документ повторно.</phrase>
<phrase data="auto_ffec67789fb1fcdc">щ</phrase>
<phrase data="runtime_05d4ae78b6f1bfee">н</phrase>
<phrase data="runtime_0918b4ba92684eaa">Название</phrase>
<phrase data="runtime_0940401102b8a3fe">т</phrase>
<phrase data="runtime_11cef8141437f8a6">ъ</phrase>
<phrase data="runtime_15f2ccabeb425c29">й</phrase>
<phrase data="runtime_1cc10a3947f7c245">д</phrase>
<phrase data="runtime_1d51ebe0fc45e38e">Только поля</phrase>
<phrase data="runtime_259f56cb715ba3a3">е</phrase>
<phrase data="runtime_3051067c6324c63f">з</phrase>
<phrase data="runtime_30fbc377ae9122ed">ё</phrase>
<phrase data="runtime_3164696756227147">Заметка добавлена</phrase>
<phrase data="runtime_34c6fbb62da8ce5c">п</phrase>
<phrase data="runtime_37d43a57a463add5">ю</phrase>
<phrase data="runtime_40831c5d98a57dfc">ч</phrase>
<phrase data="runtime_446cdc8e3c0d7efc">ы</phrase>
<phrase data="runtime_46b0d3855548054a">JSON-снимок пересобран</phrase>
<phrase data="runtime_4f60bd4e24e7dc7f">ш</phrase>
<phrase data="runtime_50338c005c41fb4e">о</phrase>
<phrase data="runtime_5225a95b111d47bf">к</phrase>
<phrase data="runtime_53b10ee4405f56d6">ещё не создан</phrase>
<phrase data="runtime_557f4cd2c6d3c150">см</phrase>
<phrase data="runtime_61095f9d62dfe291">Документ восстановлен</phrase>
<phrase data="runtime_6228f1454b196ffd">API-токен отозван</phrase>
<phrase data="runtime_65fd24f1cb37fec4">Черновик</phrase>
<phrase data="runtime_72a81659de042887">Ревизии удалены</phrase>
<phrase data="runtime_734dbc2b54ab6c95">р</phrase>
<phrase data="runtime_754309a41e0df98a">ф</phrase>
<phrase data="runtime_7592b7ec4eba966a">ц</phrase>
<phrase data="runtime_7b0ff8a2e5fdd66d">м</phrase>
<phrase data="runtime_823c4eb3e895adc9">а</phrase>
<phrase data="runtime_85011fce4419ee63">Поле #</phrase>
<phrase data="runtime_894f97c52af9e5ac">Заметка удалена</phrase>
<phrase data="runtime_9aa032336a8e122c">б</phrase>
<phrase data="runtime_9b21f5cf014c9efb">я</phrase>
<phrase data="runtime_9b6276c9a4ac5507">Документ сохранён</phrase>
<phrase data="runtime_a1f5d39a12e8f6be">с</phrase>
<phrase data="runtime_ab1f4be7e5d6fd8b">г</phrase>
<phrase data="runtime_ae51ade91d2b85b7">Ревизия удалена</phrase>
<phrase data="runtime_b8012cb642c887a0">в</phrase>
<phrase data="runtime_b95d78565f154f82">ь</phrase>
<phrase data="runtime_bb69a96efca3303e">х</phrase>
<phrase data="runtime_bbb369f210d2969f">щ</phrase>
<phrase data="runtime_beee80c256b458da">у</phrase>
<phrase data="runtime_bf0d18ef68a8a34e">можно оставить пустым</phrase>
<phrase data="runtime_c08fb476eca49399">кг</phrase>
<phrase data="runtime_c5412ba2cc2fc45a">Пресет создания сохранён</phrase>
<phrase data="runtime_c78364c5d0f27706">Б</phrase>
<phrase data="runtime_cdd02bd8b5684812">Редирект удалён</phrase>
<phrase data="runtime_d92fff560eb5495e">л</phrase>
<phrase data="runtime_dd48804aa38b03e1">Пресет удалён</phrase>
<phrase data="runtime_dd591284d827e17f">ж</phrase>
<phrase data="runtime_e1c88af54b9fced2">Документ #</phrase>
<phrase data="runtime_e35c8bb151d3530d">Редирект сохранён</phrase>
<phrase data="runtime_e93c3d6287d39cc3">API-токен создан</phrase>
<phrase data="runtime_ee71f8405f87d786">э</phrase>
<phrase data="runtime_eead37caf818d0fa">и</phrase>
<phrase data="runtime_f4019994d4e75a2a">после сохранения</phrase>
<phrase data="runtime_f9a506077fa0d961">Новый</phrase>
<phrase data="runtime_fff8b34d92dab340">Шаблон</phrase>
</language>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="documents_catalog_empty">Разделы не выбраны.</phrase>
<phrase data="documents_catalog_add">Добавить раздел</phrase>
<phrase data="documents_catalog_search">Найти раздел</phrase>
<phrase data="documents_catalog_remove">Убрать раздел</phrase>
<phrase data="documents_catalog_document_prefix">документ #</phrase>
<phrase data="documents_catalog_hidden">скрыт</phrase>
</language>
@@ -0,0 +1,225 @@
{% if catalog_mode and not is_new %}
<section class="product-promotions-panel" data-document-workspace-panel="promotions" data-product-promotions data-base="{{ ADMINX_BASE }}" data-product-id="{{ document.Id }}" data-product-title="{{ document.document_title|e('html_attr') }}" data-search-url="{{ ADMINX_BASE }}/catalog/product-relations/products" data-csrf="{{ csrf_token }}">
<header class="section-header blocks-panel-header product-promotions-header">
<div class="section-icon"><i class="ti ti-gift"></i></div>
<div>
<div class="section-eyebrow">Карточка товара</div>
<h2>Акции товара</h2>
<p class="section-desc">Настройте скидку на этот или сопутствующий товар либо выберите подарок.</p>
</div>
<div class="section-header-right"><a class="btn btn-ghost" href="{{ ADMINX_BASE }}/shop/settings?tab=promotions"><i class="ti ti-adjustments"></i>Все акции</a></div>
</header>
{% if not product_promotions_available %}
<div class="alert alert-warning product-promotions-alert"><i class="ti ti-database-exclamation"></i><div><b>Таблицы акций ещё не созданы</b><p>Примените миграции модуля «Магазин».</p></div></div>
{% elseif not can_manage_product_promotions %}
<div class="alert alert-warning product-promotions-alert"><i class="ti ti-lock"></i><div><b>Недостаточно прав</b><p>Для настройки нужны права на товары и управление заказами.</p></div></div>
{% else %}
<div class="product-promotions-layout">
<section class="product-promotions-list-panel">
<div class="product-promotions-subhead"><div><h3>Правила для товара</h3><p>Простые правила можно изменить здесь. Массовые открываются в общих настройках.</p></div><span class="badge badge-blue">{{ product_promotions|length }}</span></div>
<div class="product-promotions-list" data-product-promotion-list>
{% for item in product_promotions %}
<article class="product-promotion-row{{ not item.status ? ' is-disabled' : '' }}" data-product-promotion-row="{{ item.id }}">
<span class="icon-tile" style="--tile-bg:{{ item.reward_type == 'gift' ? 'var(--violet-100)' : 'var(--green-100)' }};--tile-fg:{{ item.reward_type == 'gift' ? 'var(--violet-600)' : 'var(--green-600)' }}"><i class="ti {{ item.reward_type == 'gift' ? 'ti-gift' : 'ti-discount-2' }}"></i></span>
<div class="product-promotion-row-copy">
<b>{{ item.title }}</b>
<small>
{% 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 %}
</small>
</div>
<div class="product-promotion-row-actions">
{% if item.direct_product_rule %}
<label class="switch" data-tooltip="{{ item.status ? 'Выключить акцию' : 'Включить акцию' }}"><input type="checkbox"{{ item.status ? ' checked' : '' }} data-product-promotion-toggle data-url="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/promotions/{{ item.id }}/toggle"><span></span></label>
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-edit" type="button" data-product-promotion-edit data-rule="{{ item|json_encode|e('html_attr') }}" data-tooltip="Изменить" aria-label="Изменить"><i class="ti ti-pencil"></i></button>
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-product-promotion-delete data-url="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/promotions/{{ item.id }}/delete" data-title="{{ item.title|e('html_attr') }}" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button>
{% else %}
<span class="badge badge-gray">общее правило</span>
<a class="btn btn-ghost btn-icon btn-sm" href="{{ ADMINX_BASE }}/shop/settings?tab=promotions" data-tooltip="Открыть общий конструктор" aria-label="Открыть общий конструктор"><i class="ti ti-external-link"></i></a>
{% endif %}
</div>
</article>
{% else %}
<div class="empty-state product-promotions-empty"><i class="ti ti-gift-off"></i><b>Акций для товара пока нет</b><span>Создайте скидку или выберите товар в подарок.</span></div>
{% endfor %}
</div>
</section>
<form class="product-promotion-form" method="post" action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/promotions/0" data-product-promotion-form>
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="code" value="">
<input type="hidden" name="priority" value="100">
<div class="product-promotions-subhead"><div><h3 data-product-promotion-form-title>Новая акция</h3><p>Редактируемый товар уже выбран как условие.</p></div><label class="switch" data-tooltip="Акция включена"><input type="checkbox" name="status" value="1" checked><span></span></label></div>
<div class="form-grid product-promotion-fields">
<label class="field col-8"><span class="field-label">Название</span><input class="input" name="title" maxlength="190" value="Акция: {{ document.document_title|e('html_attr') }}" required></label>
<label class="field col-4"><span class="field-label">При покупке, шт.</span><input class="input" type="number" name="condition_quantity" min="1" max="1000" value="1"></label>
<label class="field col-6"><span class="field-label">Что получает покупатель</span><select class="select" name="reward_type" data-product-promotion-type><option value="percent">Скидка, %</option><option value="fixed_discount">Скидка, ₽</option><option value="fixed_price">Специальная цена</option><option value="gift">Товар в подарок</option></select></label>
<label class="field col-6" data-product-promotion-value-field><span class="field-label" data-product-promotion-value-label>Размер скидки, %</span><input class="input" type="number" name="reward_value" min="0" step="0.01" value=""></label>
<label class="field col-6" data-product-promotion-target-mode-field><span class="field-label">На какой товар действует</span><select class="select" name="target_mode" data-product-promotion-target-mode><option value="current">На этот товар</option><option value="linked">На другой товар</option></select></label>
<label class="field col-6"><span class="field-label">Количество со скидкой / в подарок</span><input class="input" type="number" name="reward_quantity" min="1" max="1000" value="1"></label>
<div class="field col-12 product-promotion-picker" data-product-promotion-picker hidden>
<input type="hidden" name="target_product_id" value="">
<span class="field-label" data-product-promotion-target-label>Сопутствующий товар</span>
<div class="input-wrap"><i class="ti ti-search"></i><input class="input" type="search" autocomplete="off" placeholder="Название, артикул или ID" data-product-promotion-search></div>
<div class="product-picker-results" data-product-promotion-results hidden></div>
<div class="product-picker-selected" data-product-promotion-selected hidden></div>
</div>
<label class="field col-6"><span class="field-label">Начало</span><input class="input" type="datetime-local" name="starts_at"></label>
<label class="field col-6"><span class="field-label">Завершение</span><input class="input" type="datetime-local" name="ends_at"></label>
<label class="field col-12"><span class="field-label">Описание для покупателя</span><input class="input" name="description" maxlength="500" placeholder="Например: скидка действует при покупке вместе"></label>
<label class="product-promotion-option col-12"><span class="switch"><input type="checkbox" name="allow_coupon" value="1"><span></span></span><span><b>Разрешить применять купон</b><small>Купон будет рассчитан после акции товара.</small></span></label>
</div>
<div class="alert alert-info product-promotion-preview"><i class="ti ti-sparkles"></i><div><b>Как сработает</b><p data-product-promotion-preview>Укажите размер скидки.</p></div></div>
<div class="product-promotion-actions"><button class="btn btn-ghost" type="button" data-product-promotion-reset hidden>Отменить редактирование</button><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i><span data-product-promotion-submit>Создать акцию</span></button></div>
</form>
</div>
{% endif %}
</section>
{% endif %}
{% if catalog_mode and not is_new %}
<section class="card documents-card catalog-product-variant-bridge" data-document-workspace-panel="variants">
<div class="documents-section-head"><h2>Варианты товара</h2>{% if variant_group %}<span class="badge badge-blue">Группа #{{ variant_group.id }}</span>{% endif %}</div>
<div class="documents-card-body between">
{% if variant_group %}
<div><b>{{ variant_group.title }}</b><p class="text-secondary">{{ variant_group.is_primary ? 'Основной вариант группы' : 'Вариант группы' }}</p></div>
<a class="btn btn-secondary" href="{{ ADMINX_BASE }}/catalog/variant-groups/{{ variant_group.id }}"><i class="ti ti-box-multiple"></i>Управление вариантами</a>
{% else %}
<div><b>Товар пока не объединён с вариантами</b><p class="text-secondary">Создайте группу на основе текущего товара.</p></div>
<form method="post" action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/variant-group" data-variant-action>
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="title" value="{{ document.document_title|e('html_attr') }}">
<button class="btn btn-secondary" type="submit"><i class="ti ti-plus"></i>Создать группу вариантов</button>
</form>
{% endif %}
</div>
</section>
{% endif %}
{% if catalog_mode and not is_new %}
<section class="product-native-attributes" data-document-workspace-panel="attributes">
{% if native_attributes.attributes_count %}
<form class="native-attributes-form" method="post" action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/attributes" data-attribute-ajax>
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<header class="section-header blocks-panel-header native-attributes-panel-header">
<div class="section-icon"><i class="ti ti-list-details"></i></div>
<div>
<div class="section-eyebrow">Карточка товара</div>
<h2>Нативные характеристики</h2>
<p class="section-desc">Характеристики сгруппированы по назначению. Единицы измерения сохраняются вместе со схемой и не вводятся вручную.</p>
</div>
<div class="section-header-right"><span class="badge badge-violet">{{ native_attributes.attributes_count }} полей</span></div>
</header>
<div class="native-attribute-groups">
{% for group in native_attributes.groups %}
<section class="card native-attribute-group">
<div class="native-attribute-group-head"><div><b>{{ group.name }}</b><small>{{ group.set_name }}</small></div><span class="badge badge-gray">{{ group.items|length }}</span></div>
<div class="native-attribute-grid">
{% for item in group.items %}
{% if item.value_type == 'multi_choice' and item.option_items %}
<fieldset class="field native-attribute-field native-attribute-multi-field">
<legend class="field-label">{{ item.label_override ?: item.name }}{% if item.required %}<span class="ax-required">*</span>{% endif %}</legend>
<div class="native-attribute-option-grid">
{% for option in item.option_items %}
<label class="check native-attribute-option" data-native-attribute-option>
<input type="checkbox" name="attribute[{{ item.id }}][]" value="{{ option.value_key|e('html_attr') }}"{{ item.value is iterable and option.value_key in item.value ? ' checked' : '' }}>
{% if option.swatch %}<i class="native-attribute-option-swatch" style="--attribute-swatch:{{ option.swatch|e('html_attr') }}"></i>{% endif %}
<span><b>{{ option.label }}</b>{% if option.legacy %}<small>Прежнее значение</small>{% endif %}</span>
</label>
{% endfor %}
</div>
<span class="native-attribute-meta"><span class="mono">{{ item.code }}</span>{% if item.value_state %}<span class="badge {{ item.value_state == 'verified' ? 'badge-green' : 'badge-amber' }}">{{ item.value_state == 'verified' ? 'проверено' : 'черновик' }}</span>{% endif %}</span>
</fieldset>
{% else %}
<label class="field native-attribute-field{{ item.editor == 'textarea' ? ' native-attribute-textarea-field' : '' }}">
<span class="field-label">{{ item.label_override ?: item.name }}{% if item.required %}<span class="ax-required">*</span>{% endif %}</span>
{% if item.value_type == 'boolean' %}
<select class="select" name="attribute[{{ item.id }}]"><option value="">Не задано</option><option value="1" {{ item.value is same as(true) or item.value == '1' ? 'selected' : '' }}>Да</option><option value="0" {{ item.value is same as(false) and item.value_json != '' or item.value == '0' ? 'selected' : '' }}>Нет</option></select>
{% elseif item.value_type == 'choice' and item.option_items %}
<span class="native-attribute-choice" data-native-attribute-choice><select class="select" name="attribute[{{ item.id }}]"><option value="" data-swatch="">Не задано</option>{% for option in item.option_items %}<option value="{{ option.value_key|e('html_attr') }}" data-swatch="{{ option.swatch|e('html_attr') }}" {{ item.value == option.value_key ? 'selected' : '' }}>{{ option.label }}{% if option.legacy %} · прежнее значение{% endif %}</option>{% endfor %}</select><span class="native-attribute-choice-swatch" data-native-attribute-choice-swatch hidden></span></span>
{% elseif item.value_type == 'number' %}
<span class="input-group native-attribute-input-group"><input class="input" type="number" step="any" name="attribute[{{ item.id }}]" value="{{ item.value }}">{% if item.unit %}<span class="input-addon">{{ item.unit }}</span>{% endif %}</span>
{% elseif item.value_type == 'date' %}
<input class="input" type="date" name="attribute[{{ item.id }}]" value="{{ item.value }}">
{% elseif item.value_type == 'multi_choice' %}
<textarea class="textarea" rows="3" name="attribute[{{ item.id }}]">{{ item.value is iterable ? item.value|join('\n') : item.value }}</textarea>
{% elseif item.editor == 'textarea' %}
<textarea class="textarea native-attribute-textarea" rows="5" name="attribute[{{ item.id }}]">{{ item.value is iterable ? item.value|join(', ') : item.value }}</textarea>
{% else %}
{% if item.unit %}<span class="input-group native-attribute-input-group"><input class="input" type="text" name="attribute[{{ item.id }}]" value="{{ item.value is iterable ? item.value|join(', ') : item.value }}"><span class="input-addon">{{ item.unit }}</span></span>{% else %}<input class="input" type="text" name="attribute[{{ item.id }}]" value="{{ item.value is iterable ? item.value|join(', ') : item.value }}">{% endif %}
{% endif %}
<span class="native-attribute-meta"><span class="mono">{{ item.code }}</span>{% if item.value_state %}<span class="badge {{ item.value_state == 'verified' ? 'badge-green' : 'badge-amber' }}">{{ item.value_state == 'verified' ? 'проверено' : 'черновик' }}</span>{% endif %}</span>
</label>
{% endif %}
{% endfor %}
</div>
</section>
{% endfor %}
<div class="native-attribute-footer"><a class="btn btn-ghost" href="{{ ADMINX_BASE }}/catalog/attributes?view=sections"><i class="ti ti-settings"></i>Настроить наборы</a><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i>Сохранить характеристики</button></div>
</div>
</form>
{% else %}
<header class="section-header blocks-panel-header native-attributes-panel-header">
<div class="section-icon"><i class="ti ti-list-details"></i></div>
<div>
<div class="section-eyebrow">Карточка товара</div>
<h2>Нативные характеристики</h2>
<p class="section-desc">Для раздела товара пока не назначены характеристики.</p>
</div>
</header>
<div class="card native-attribute-empty">
<div class="documents-placeholder">
<span class="icon-tile" style="--tile-bg:var(--gray-100);--tile-fg:var(--gray-600)"><i class="ti ti-list-search"></i></span>
<div><b>Характеристик пока нет</b><p class="text-secondary">Назначьте набор характеристик разделу каталога.</p></div>
<a class="btn btn-secondary" href="{{ ADMINX_BASE }}/catalog/attributes?view=sections"><i class="ti ti-settings"></i>Настроить наборы</a>
</div>
</div>
{% endif %}
</section>
{% endif %}
{% if catalog_mode and not is_new %}
<section class="card documents-card product-shipping-card" data-product-shipping data-document-workspace-panel="shipping">
<form data-product-shipping-form action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/shipping" method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="packages" value="">
<div class="documents-section-head product-shipping-head">
<div class="documents-head-left"><span class="icon-tile documents-head-icon" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-package-export"></i></span><div><h2>Доставка и упаковка</h2><p class="text-secondary">Физические грузовые места для расчёта перевозчиками.</p></div></div>
<label class="switch"><input type="checkbox" name="shipping_enabled" value="1"{{ shipping_profile.shipping_enabled ? ' checked' : '' }}><span></span><b>Рассчитывать доставку</b></label>
</div>
<div class="documents-card-body product-shipping-body">
{% if shipping_profile.legacy %}<div class="product-shipping-legacy"><i class="ti ti-info-circle"></i><span>Найдены старые данные: {% for alias,value in shipping_profile.legacy %}<b>{{ alias }}</b> {{ value }}{% if not loop.last %}, {% endif %}{% endfor %}. Добавьте грузовое место, чтобы перейти на точный расчёт.</span></div>{% endif %}
<div class="product-package-template-picker"><select class="select" data-package-template-select><option value="">Добавить готовую упаковку</option>{% for template in package_templates %}<option value="{{ template.id }}" data-package="{{ template|json_encode|e('html_attr') }}">{{ template.title }} · {{ template.dimensions }} · {{ template.weight_label }}</option>{% endfor %}</select><button class="btn btn-secondary" type="button" data-package-template-apply disabled><i class="ti ti-plus"></i>Добавить</button><a class="btn btn-ghost btn-icon" href="{{ ADMINX_BASE }}/catalog/products/shipping" data-tooltip="Управление шаблонами" aria-label="Управление шаблонами упаковки"><i class="ti ti-settings"></i></a></div>
<div class="product-package-head" aria-hidden="true"><span>Грузовое место</span><span>Кол-во</span><span>Вес, кг</span><span>Длина, см</span><span>Ширина, см</span><span>Высота, см</span><span></span></div>
<div class="product-package-list" data-package-list>
{% for package in shipping_profile.packages %}
<div class="product-package-row" data-package-row>
<input class="input" name="title" value="{{ package.title|e('html_attr') }}" aria-label="Название грузового места">
<input class="input" type="number" name="quantity" min="1" max="999" step="1" value="{{ package.quantity }}" aria-label="Количество">
<input class="input" type="number" name="weight_kg" min="0" step="0.001" value="{{ package.weight_kg }}" aria-label="Вес в килограммах">
<input class="input" type="number" name="length_cm" min="0" step="0.1" value="{{ package.length_cm }}" aria-label="Длина в сантиметрах">
<input class="input" type="number" name="width_cm" min="0" step="0.1" value="{{ package.width_cm }}" aria-label="Ширина в сантиметрах">
<input class="input" type="number" name="height_cm" min="0" step="0.1" value="{{ package.height_cm }}" aria-label="Высота в сантиметрах">
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-package-remove data-tooltip="Удалить грузовое место" aria-label="Удалить грузовое место"><i class="ti ti-trash"></i></button>
</div>
{% endfor %}
</div>
<div class="product-shipping-empty" data-package-empty{{ shipping_profile.packages ? ' hidden' : '' }}><i class="ti ti-package-off"></i><span>Грузовые места ещё не добавлены</span></div>
<div class="product-shipping-footer">
<div class="product-shipping-summary"><span><b data-package-places>{{ shipping_profile.summary.places }}</b> мест</span><span><b data-package-weight>{{ shipping_profile.summary.weight_kg|number_format(3, '.', ' ') }}</b> кг</span><span><b data-package-volume>{{ shipping_profile.summary.volume_m3|number_format(4, '.', ' ') }}</b> м³</span><span class="badge {{ shipping_profile.complete ? 'badge-green' : 'badge-amber' }}" data-package-state>{{ shipping_profile.complete ? 'данные заполнены' : 'нужны габариты' }}</span></div>
<div class="cluster">{% if variant_group %}<button class="btn btn-secondary" type="button" data-shipping-copy-variants data-url="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/shipping/copy-to-variants" data-csrf="{{ csrf_token }}" data-tooltip="Заменить упаковку у всех вариантов"><i class="ti ti-copy"></i>Вариантам</button>{% endif %}<button class="btn btn-secondary" type="button" data-package-add><i class="ti ti-plus"></i>Добавить место</button><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i>Сохранить упаковку</button></div>
</div>
</div>
</form>
<template data-package-template><div class="product-package-row" data-package-row><input class="input" name="title" value="" placeholder="Коробка 1" aria-label="Название грузового места"><input class="input" type="number" name="quantity" min="1" max="999" step="1" value="1" aria-label="Количество"><input class="input" type="number" name="weight_kg" min="0" step="0.001" value="" placeholder="0" aria-label="Вес в килограммах"><input class="input" type="number" name="length_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Длина в сантиметрах"><input class="input" type="number" name="width_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Ширина в сантиметрах"><input class="input" type="number" name="height_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Высота в сантиметрах"><button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-package-remove data-tooltip="Удалить грузовое место" aria-label="Удалить грузовое место"><i class="ti ti-trash"></i></button></div></template>
</section>
{% endif %}
+92 -64
View File
@@ -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 %}
<nav class="breadcrumbs" aria-label="Хлебные крошки">
@@ -11,72 +11,61 @@
<div class="page-header documents-edit-header">
<div class="between">
<div>
<h1 data-document-edit-heading>{{ is_new ? 'Новый документ' : document.document_title }}</h1>
<p class="text-secondary" style="margin-top:5px" data-document-edit-meta>{{ is_new ? 'Создание базовой карточки документа.' : 'ID ' ~ document.Id ~ ', ' ~ document.state_label ~ ', изменён: ' ~ document.changed_label }}</p>
<h1 data-document-edit-heading>{{ is_new ? (catalog_mode ? 'Новый товар' : 'Новый документ') : document.document_title }}</h1>
<p class="text-secondary" style="margin-top:5px" data-document-edit-meta>{{ is_new ? (catalog_mode ? 'Заполните основные данные. Характеристики, варианты и доставка откроются после первого сохранения.' : 'Создание базовой карточки документа.') : 'ID ' ~ document.Id ~ ', ' ~ (document.state_label|admin_trans) ~ ', изменён: ' ~ document.changed_label }}</p>
</div>
<div class="cluster">
<div class="segmented documents-editor-modes" role="group" aria-label="Режим редактора">
<button class="segmented-item" type="button" data-document-editor-mode="quick" data-tooltip="Только основное и поля"><i class="ti ti-bolt"></i><span>Быстро</span></button>
<button class="segmented-item" type="button" data-document-editor-mode="normal" data-tooltip="Основное, поля и публикация"><i class="ti ti-layout-columns"></i><span>Обычно</span></button>
<button class="segmented-item" type="button" data-document-editor-mode="advanced" data-tooltip="Все настройки документа"><i class="ti ti-adjustments-horizontal"></i><span>Расширенно</span></button>
</div>
<a class="btn btn-ghost" href="{{ return_url|default(ADMINX_BASE ~ '/documents') }}"><i class="ti ti-arrow-left"></i>К списку</a>
</div>
</div>
</div>
{% if catalog_mode and not is_new %}<section class="card documents-card catalog-product-variant-bridge"><div class="documents-section-head"><h2>Варианты товара</h2>{% if variant_group %}<span class="badge badge-blue">Группа #{{ variant_group.id }}</span>{% endif %}</div><div class="documents-card-body between">{% if variant_group %}<div><b>{{ variant_group.title }}</b><p class="text-secondary">{{ variant_group.is_primary ? 'Основной вариант группы' : 'Вариант группы' }}</p></div><a class="btn btn-secondary" href="{{ ADMINX_BASE }}/catalog/variant-groups/{{ variant_group.id }}"><i class="ti ti-box-multiple"></i>Управление вариантами</a>{% else %}<div><b>Товар пока не объединён с вариантами</b><p class="text-secondary">Создайте группу на основе текущего товара.</p></div><form method="post" action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/variant-group" data-variant-action><input type="hidden" name="_csrf" value="{{ csrf_token }}"><input type="hidden" name="title" value="{{ document.document_title|e('html_attr') }}"><button class="btn btn-secondary" type="submit"><i class="ti ti-plus"></i>Создать группу вариантов</button></form>{% endif %}</div></section>{% endif %}
{% if catalog_mode and not is_new and native_attributes.attributes_count %}
<section class="card documents-card product-native-attributes"><form method="post" action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/attributes" data-attribute-ajax><input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="documents-section-head"><div class="documents-head-left"><span class="icon-tile documents-head-icon" style="--tile-bg:var(--violet-100);--tile-fg:var(--violet-600)"><i class="ti ti-list-details"></i></span><div><h2>Нативные характеристики</h2><p class="text-secondary">Новый контур проверки. Публичная карточка пока использует старые поля.</p></div></div><span class="badge badge-violet">{{ native_attributes.attributes_count }}</span></div>
<div class="documents-card-body native-attribute-groups">{% for group in native_attributes.groups %}<section class="native-attribute-group"><div class="native-attribute-group-head"><div><b>{{ group.name }}</b><small>{{ group.set_name }}</small></div><span>{{ group.items|length }}</span></div><div class="native-attribute-grid">{% for item in group.items %}<label class="field native-attribute-field"><span class="field-label">{{ item.label_override ?: item.name }}{% if item.required %}<span class="ax-required">*</span>{% endif %}{% if item.unit %}<small>{{ item.unit }}</small>{% endif %}</span>
{% if item.value_type == 'boolean' %}<select class="select" name="attribute[{{ item.id }}]"><option value="">Не задано</option><option value="1" {{ item.value is same as(true) or item.value == '1' ? 'selected' : '' }}>Да</option><option value="0" {{ item.value is same as(false) and item.value_json != '' or item.value == '0' ? 'selected' : '' }}>Нет</option></select>
{% elseif item.value_type == 'choice' and item.option_items %}<span class="native-attribute-choice" data-native-attribute-choice><select class="select" name="attribute[{{ item.id }}]"><option value="" data-swatch="">Не задано</option>{% for option in item.option_items %}<option value="{{ option.value_key|e('html_attr') }}" data-swatch="{{ option.swatch|e('html_attr') }}" {{ item.value == option.value_key ? 'selected' : '' }}>{{ option.label }}{% if option.legacy %} · прежнее значение{% endif %}</option>{% endfor %}</select><span class="native-attribute-choice-swatch" data-native-attribute-choice-swatch hidden></span></span>
{% elseif item.value_type == 'number' %}<input class="input" type="number" step="any" name="attribute[{{ item.id }}]" value="{{ item.value }}">
{% elseif item.value_type == 'date' %}<input class="input" type="date" name="attribute[{{ item.id }}]" value="{{ item.value }}">
{% elseif item.value_type == 'multi_choice' and item.option_items %}<select class="select native-attribute-multi" name="attribute[{{ item.id }}][]" multiple size="{{ item.option_items|length > 6 ? 6 : item.option_items|length }}">{% for option in item.option_items %}<option value="{{ option.value_key|e('html_attr') }}" {{ item.value is iterable and option.value_key in item.value ? 'selected' : '' }}>{{ option.label }}{% if option.swatch %} · {{ option.swatch }}{% endif %}{% if option.legacy %} · прежнее значение{% endif %}</option>{% endfor %}</select>
{% elseif item.value_type == 'multi_choice' %}<textarea class="textarea" rows="3" name="attribute[{{ item.id }}]">{{ item.value is iterable ? item.value|join('\n') : item.value }}</textarea>
{% else %}<input class="input" type="text" name="attribute[{{ item.id }}]" value="{{ item.value is iterable ? item.value|join(', ') : item.value }}">{% endif %}
<span class="native-attribute-meta"><span class="mono">{{ item.code }}</span>{% if item.value_state %}<span class="badge {{ item.value_state == 'verified' ? 'badge-green' : 'badge-amber' }}">{{ item.value_state == 'verified' ? 'проверено' : 'черновик' }}</span>{% endif %}</span></label>{% endfor %}</div></section>{% endfor %}<div class="native-attribute-footer"><a class="btn btn-ghost" href="{{ ADMINX_BASE }}/catalog/attributes?view=sections"><i class="ti ti-settings"></i>Настроить наборы</a><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i>Сохранить характеристики</button></div></div>
</form></section>
{% endif %}
{% if catalog_mode and not is_new %}
<section class="card documents-card product-shipping-card" data-product-shipping>
<form data-product-shipping-form action="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/shipping" method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="packages" value="">
<div class="documents-section-head product-shipping-head">
<div class="documents-head-left"><span class="icon-tile documents-head-icon" style="--tile-bg:var(--cyan-100);--tile-fg:var(--cyan-600)"><i class="ti ti-package-export"></i></span><div><h2>Доставка и упаковка</h2><p class="text-secondary">Физические грузовые места для расчёта перевозчиками.</p></div></div>
<label class="switch"><input type="checkbox" name="shipping_enabled" value="1"{{ shipping_profile.shipping_enabled ? ' checked' : '' }}><span></span><b>Рассчитывать доставку</b></label>
<div class="documents-editor-workspace" data-document-workspace data-active-panel="main" data-catalog-mode="{{ catalog_mode ? '1' : '0' }}" data-is-new="{{ is_new ? '1' : '0' }}">
<div class="documents-workspace-navigation">
<div class="tabs documents-workspace-tabs" role="tablist" aria-label="{{ catalog_mode ? 'Разделы товара' : 'Разделы документа' }}">
<button class="tab is-active" type="button" role="tab" aria-selected="true" data-document-workspace-tab="main"><i class="ti ti-file-description"></i>Основное</button>
{% if catalog_mode %}
<button class="tab" type="button" role="tab" aria-selected="false" data-document-workspace-tab="promotions"{{ is_new ? ' aria-disabled="true" data-tooltip="Доступно после первого сохранения"' : '' }}><i class="ti ti-gift"></i>Акции</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-document-workspace-tab="attributes"{{ is_new ? ' aria-disabled="true" data-tooltip="Доступно после первого сохранения"' : '' }}><i class="ti ti-list-details"></i>Характеристики</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-document-workspace-tab="variants"{{ is_new ? ' aria-disabled="true" data-tooltip="Доступно после первого сохранения"' : '' }}><i class="ti ti-box-multiple"></i>Варианты</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-document-workspace-tab="shipping"{{ is_new ? ' aria-disabled="true" data-tooltip="Доступно после первого сохранения"' : '' }}><i class="ti ti-truck-delivery"></i>Доставка</button>
{% endif %}
<button class="tab" type="button" role="tab" aria-selected="false" data-document-workspace-tab="additional"><i class="ti ti-adjustments-horizontal"></i>Дополнительно</button>
</div>
<div class="documents-card-body product-shipping-body">
{% if shipping_profile.legacy %}<div class="product-shipping-legacy"><i class="ti ti-info-circle"></i><span>Найдены старые данные: {% for alias,value in shipping_profile.legacy %}<b>{{ alias }}</b> {{ value }}{% if not loop.last %}, {% endif %}{% endfor %}. Добавьте грузовое место, чтобы перейти на точный расчёт.</span></div>{% endif %}
<div class="product-package-template-picker"><select class="select" data-package-template-select><option value="">Добавить готовую упаковку</option>{% for template in package_templates %}<option value="{{ template.id }}" data-package="{{ template|json_encode|e('html_attr') }}">{{ template.title }} · {{ template.dimensions }} · {{ template.weight_label }}</option>{% endfor %}</select><button class="btn btn-secondary" type="button" data-package-template-apply disabled><i class="ti ti-plus"></i>Добавить</button><a class="btn btn-ghost btn-icon" href="{{ ADMINX_BASE }}/catalog/products/shipping" data-tooltip="Управление шаблонами" aria-label="Управление шаблонами упаковки"><i class="ti ti-settings"></i></a></div>
<div class="product-package-head" aria-hidden="true"><span>Грузовое место</span><span>Кол-во</span><span>Вес, кг</span><span>Длина, см</span><span>Ширина, см</span><span>Высота, см</span><span></span></div>
<div class="product-package-list" data-package-list>
{% for package in shipping_profile.packages %}
<div class="product-package-row" data-package-row>
<input class="input" name="title" value="{{ package.title|e('html_attr') }}" aria-label="Название грузового места">
<input class="input" type="number" name="quantity" min="1" max="999" step="1" value="{{ package.quantity }}" aria-label="Количество">
<input class="input" type="number" name="weight_kg" min="0" step="0.001" value="{{ package.weight_kg }}" aria-label="Вес в килограммах">
<input class="input" type="number" name="length_cm" min="0" step="0.1" value="{{ package.length_cm }}" aria-label="Длина в сантиметрах">
<input class="input" type="number" name="width_cm" min="0" step="0.1" value="{{ package.width_cm }}" aria-label="Ширина в сантиметрах">
<input class="input" type="number" name="height_cm" min="0" step="0.1" value="{{ package.height_cm }}" aria-label="Высота в сантиметрах">
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-package-remove data-tooltip="Удалить грузовое место" aria-label="Удалить грузовое место"><i class="ti ti-trash"></i></button>
</div>
{% endfor %}
</div>
<div class="product-shipping-empty" data-package-empty{{ shipping_profile.packages ? ' hidden' : '' }}><i class="ti ti-package-off"></i><span>Грузовые места ещё не добавлены</span></div>
<div class="product-shipping-footer"><div class="product-shipping-summary"><span><b data-package-places>{{ shipping_profile.summary.places }}</b> мест</span><span><b data-package-weight>{{ shipping_profile.summary.weight_kg|number_format(3, '.', ' ') }}</b> кг</span><span><b data-package-volume>{{ shipping_profile.summary.volume_m3|number_format(4, '.', ' ') }}</b> м³</span><span class="badge {{ shipping_profile.complete ? 'badge-green' : 'badge-amber' }}" data-package-state>{{ shipping_profile.complete ? 'данные заполнены' : 'нужны габариты' }}</span></div><div class="cluster">{% if variant_group %}<button class="btn btn-secondary" type="button" data-shipping-copy-variants data-url="{{ ADMINX_BASE }}/catalog/products/{{ document.Id }}/shipping/copy-to-variants" data-csrf="{{ csrf_token }}" data-tooltip="Заменить упаковку у всех вариантов"><i class="ti ti-copy"></i>Вариантам</button>{% endif %}<button class="btn btn-secondary" type="button" data-package-add><i class="ti ti-plus"></i>Добавить место</button><button class="btn btn-primary" type="submit"><i class="ti ti-device-floppy"></i>Сохранить упаковку</button></div></div>
</div>
</form>
<template data-package-template><div class="product-package-row" data-package-row><input class="input" name="title" value="" placeholder="Коробка 1" aria-label="Название грузового места"><input class="input" type="number" name="quantity" min="1" max="999" step="1" value="1" aria-label="Количество"><input class="input" type="number" name="weight_kg" min="0" step="0.001" value="" placeholder="0" aria-label="Вес в килограммах"><input class="input" type="number" name="length_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Длина в сантиметрах"><input class="input" type="number" name="width_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Ширина в сантиметрах"><input class="input" type="number" name="height_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Высота в сантиметрах"><button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-package-remove data-tooltip="Удалить грузовое место" aria-label="Удалить грузовое место"><i class="ti ti-trash"></i></button></div></template>
</section>
{% endif %}
<label class="field documents-workspace-mobile">
<span class="field-label">{{ catalog_mode ? 'Раздел товара' : 'Раздел документа' }}</span>
<select class="select" data-document-workspace-select>
<option value="main">Основное</option>
{% if catalog_mode %}
<option value="promotions"{{ is_new ? ' disabled' : '' }}>Акции{{ is_new ? ' · после сохранения' : '' }}</option>
<option value="attributes"{{ is_new ? ' disabled' : '' }}>Характеристики{{ is_new ? ' · после сохранения' : '' }}</option>
<option value="variants"{{ is_new ? ' disabled' : '' }}>Варианты{{ is_new ? ' · после сохранения' : '' }}</option>
<option value="shipping"{{ is_new ? ' disabled' : '' }}>Доставка{{ is_new ? ' · после сохранения' : '' }}</option>
{% endif %}
<option value="additional">Дополнительно</option>
</select>
</label>
</div>
<form id="documentForm" class="documents-edit-form" data-base="{{ ADMINX_BASE }}" data-id="{{ document.Id }}" data-rubric-id="{{ document.rubric_id }}" data-actor-id="{{ actor_id|default(0) }}" data-submit-url="{{ submit_url|default('') }}" data-return-url="{{ return_url|default('') }}" data-stay-url-template="{{ stay_url_template|default('') }}" data-quick-edit="{{ quick_edit ? '1' : '0' }}" data-editor-mode="normal">
{% if catalog_mode and not is_new and product_readiness.items %}
<section class="product-readiness-strip" aria-label="Готовность товара">
<div class="product-readiness-summary">
<span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-checklist"></i></span>
<span><b>Готовность товара</b><small>{{ product_readiness.attention ? product_readiness.attention ~ ' раздела требуют внимания' : 'Обязательные данные заполнены' }}</small></span>
</div>
<div class="product-readiness-items">
{% for item in product_readiness.items %}
<button class="product-readiness-item is-{{ item.state }}" type="button" data-document-workspace-open="{{ item.panel }}">
<i class="ti {{ item.icon }}"></i>
<span><b>{{ item.title }}</b><small>{{ item.label }}</small></span>
<i class="ti {{ item.state == 'ready' ? 'ti-circle-check-filled' : (item.state == 'warning' ? 'ti-alert-circle-filled' : 'ti-chevron-right') }} product-readiness-state"></i>
</button>
{% endfor %}
</div>
</section>
{% endif %}
<form id="documentForm" class="documents-edit-form" data-base="{{ ADMINX_BASE }}" data-id="{{ document.Id }}" data-rubric-id="{{ document.rubric_id }}" data-actor-id="{{ actor_id|default(0) }}" data-submit-url="{{ submit_url|default('') }}" data-return-url="{{ return_url|default('') }}" data-stay-url-template="{{ stay_url_template|default('') }}" data-quick-edit="{{ quick_edit ? '1' : '0' }}">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="id" value="{{ document.Id }}">
<input type="hidden" name="document_version" value="{{ document.document_version|default(1) }}">
@@ -101,7 +90,7 @@
</section>
{% endif %}
<div class="documents-edit-top" data-editor-level="advanced">
<div class="documents-edit-top" data-document-section="additional">
<section class="card documents-card documents-edit-card" style="--sec:var(--amber-600);--sec-soft:var(--amber-100)">
<div class="documents-section-head">
<div class="documents-head-left">
@@ -172,7 +161,7 @@
<div class="documents-edit-layout">
<div class="stack documents-edit-main">
<section class="card documents-card documents-edit-card documents-section-main" style="--sec:var(--blue-600);--sec-soft:var(--blue-100)">
<section class="card documents-card documents-edit-card documents-section-main" data-document-section="main" style="--sec:var(--blue-600);--sec-soft:var(--blue-100)">
<div class="documents-section-head">
<div class="documents-head-left">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--sec-soft);--tile-fg:var(--sec)"><i class="ti ti-file-text"></i></span>
@@ -231,7 +220,7 @@
</div>
</section>
<section class="card documents-card documents-edit-card documents-section-seo" data-editor-level="advanced" style="--sec:var(--green-600);--sec-soft:var(--green-100)">
<section class="card documents-card documents-edit-card documents-section-seo" data-document-section="additional" style="--sec:var(--green-600);--sec-soft:var(--green-100)">
<div class="documents-section-head">
<div class="documents-head-left">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--sec-soft);--tile-fg:var(--sec)"><i class="ti ti-search"></i></span>
@@ -294,7 +283,7 @@
</div>
<aside class="stack documents-edit-side">
<section class="card documents-card documents-edit-card documents-section-publish" data-editor-level="normal" style="--sec:var(--green-600);--sec-soft:var(--green-100)">
<section class="card documents-card documents-edit-card documents-section-publish" data-document-section="main" style="--sec:var(--green-600);--sec-soft:var(--green-100)">
<div class="documents-section-head">
<div class="documents-head-left">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--sec-soft);--tile-fg:var(--sec)"><i class="ti ti-rocket"></i></span>
@@ -321,7 +310,7 @@
</div>
</section>
<section class="card documents-card documents-edit-card documents-section-dates" data-editor-level="advanced" style="--sec:var(--teal-600);--sec-soft:var(--teal-100)">
<section class="card documents-card documents-edit-card documents-section-dates" data-document-section="additional" style="--sec:var(--teal-600);--sec-soft:var(--teal-100)">
<div class="documents-section-head">
<div class="documents-head-left">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--sec-soft);--tile-fg:var(--sec)"><i class="ti ti-calendar-event"></i></span>
@@ -339,7 +328,7 @@
</aside>
</div>
<section class="documents-fields-section documents-section-fields" style="--sec:var(--violet-600);--sec-soft:var(--violet-100)">
<section class="documents-fields-section documents-section-fields" data-document-section="main" style="--sec:var(--violet-600);--sec-soft:var(--violet-100)">
<div class="documents-fields-heading">
<div class="documents-head-left">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--sec-soft);--tile-fg:var(--sec)"><i class="ti ti-forms"></i></span>
@@ -394,6 +383,42 @@
</div>
</section>
{% if not is_new %}
<section class="card documents-card documents-edit-card documents-relations-section" data-document-section="additional" style="--sec:var(--cyan-600);--sec-soft:var(--cyan-100)">
<div class="documents-section-head">
<div class="documents-head-left">
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--sec-soft);--tile-fg:var(--sec)"><i class="ti ti-arrows-exchange"></i></span>
<div><h2>Связи документов</h2><p>Прямые и обратные связи из полей документов.</p></div>
</div>
<span class="badge badge-cyan">{{ document_relations.outgoing|length + document_relations.incoming|length }}</span>
</div>
<div class="documents-card-body documents-relations-grid">
<div class="documents-relation-list">
<div class="documents-relation-title"><b>Этот документ ссылается</b><span>{{ document_relations.outgoing|length }}</span></div>
{% for relation in document_relations.outgoing %}
<a class="documents-relation-row" href="{{ ADMINX_BASE }}/documents/{{ relation.target_document_id }}/edit">
<span><b>{{ relation.target_title ?: 'Документ #' ~ relation.target_document_id }}</b><small>{{ relation.target_rubric_title }} · {{ relation.field_title ?: relation.field_alias }}</small></span>
<i class="ti ti-arrow-right"></i>
</a>
{% else %}
<p class="text-secondary text-sm">Исходящих связей нет.</p>
{% endfor %}
</div>
<div class="documents-relation-list">
<div class="documents-relation-title"><b>На этот документ ссылаются</b><span>{{ document_relations.incoming|length }}</span></div>
{% for relation in document_relations.incoming %}
<a class="documents-relation-row" href="{{ ADMINX_BASE }}/documents/{{ relation.source_document_id }}/edit">
<span><b>{{ relation.source_title ?: 'Документ #' ~ relation.source_document_id }}</b><small>{{ relation.source_rubric_title }} · {{ relation.field_title ?: relation.field_alias }}</small></span>
<i class="ti ti-arrow-right"></i>
</a>
{% else %}
<p class="text-secondary text-sm">Обратных связей нет.</p>
{% endfor %}
</div>
</div>
</section>
{% endif %}
<div class="sticky-actions documents-sticky-actions">
<div class="documents-save-state" data-document-save-state data-state="saved" role="status" aria-live="polite">
<span class="documents-save-state-icon"><i class="ti ti-circle-check" data-document-save-state-icon></i></span>
@@ -407,6 +432,9 @@
</div>
</form>
{% include '@documents/_product_panels.twig' %}
</div>
{% if can_manage %}
<aside class="drawer drawer-right drawer-lg documents-preset-panel" id="documentPresetDrawer" role="dialog" aria-modal="true" aria-labelledby="documentPresetDrawerTitle" hidden>
<form data-document-preset-form action="{{ ADMINX_BASE }}/documents/{{ document.Id }}/presets" method="post">
+3 -2
View File
@@ -33,12 +33,13 @@
$q = Request::getStr('q', '');
$limit = Request::getInt('limit', 300);
$sourceDef = Model::source($source);
$rows = Model::rows($sourceDef['code'], $q, $limit);
return $this->render('@events/index.twig', array(
'sources' => Model::summaries(),
'active_source' => $sourceDef['code'],
'active_source_def' => $sourceDef,
'rows' => Model::rows($sourceDef['code'], $q, $limit),
'rows' => $rows,
'q' => $q,
'limit' => $limit,
'can_manage' => Permission::check('manage_events'),
@@ -56,7 +57,7 @@
return $this->error('Этот источник нельзя очистить', array(), 422);
}
return $this->success('Журнал очищен', array(
return $this->success($source === 'audit' ? 'Аудит очищен' : 'Журнал очищен', array(
'redirect' => $this->base() . '/events?source=' . rawurlencode($source),
));
}
+28 -6
View File
@@ -29,7 +29,7 @@
'audit' => array(
'code' => 'audit',
'label' => 'Аудит',
'description' => 'Административные действия в панели управления.',
'description' => 'История административных действий с пользователями, объектами и техническими деталями.',
'icon' => 'ti ti-shield-check',
'tile_bg' => 'var(--blue-100)',
'tile_fg' => 'var(--blue-600)',
@@ -155,7 +155,7 @@
AuditLog::record('events.audit_cleared', array(
'actor_id' => (int) $actorId > 0 ? (int) $actorId : null,
'target_type' => 'audit_log',
'meta' => array('deleted_rows' => $count),
'meta' => array('deleted_rows' => $count, 'source' => $source['code']),
));
return true;
}
@@ -226,20 +226,24 @@
{
$rows = DB::query(
'SELECT * FROM ' . AuditLog::table() . ' ORDER BY created_at DESC, id DESC LIMIT ' . (int) $limit
)->getAll();
)->getAll() ?: array();
$out = array();
foreach ($rows as $row) {
$meta = self::decodeMeta(isset($row['meta']) ? $row['meta'] : '');
$state = self::auditState((string) $row['action']);
$timestamp = strtotime((string) $row['created_at']);
$out[] = array(
'id' => (int) $row['id'],
'source' => 'audit',
'level' => $state,
'time' => strtotime($row['created_at']),
'time_display' => self::formatTime(strtotime($row['created_at'])),
'time' => $timestamp,
'time_display' => self::formatTime($timestamp),
'date_group' => self::dateGroup($timestamp),
'ip' => (string) $row['ip'],
'actor' => $row['actor_name'] !== '' ? $row['actor_name'] : ('#' . (int) $row['actor_id']),
'actor' => (string) $row['actor_name'] !== ''
? (string) $row['actor_name']
: '#' . (int) $row['actor_id'],
'message' => AuditLog::actionLabel($row['action']),
'url' => trim((string) $row['target_type'] . ($row['target_id'] ? ' #' . $row['target_id'] : '')),
'details' => $meta,
@@ -250,6 +254,24 @@
return $out;
}
protected static function dateGroup($timestamp)
{
$timestamp = (int) $timestamp;
if ($timestamp <= 0) {
return 'Без даты';
}
if (date('Y-m-d', $timestamp) === date('Y-m-d')) {
return 'Сегодня';
}
if (date('Y-m-d', $timestamp) === date('Y-m-d', strtotime('-1 day'))) {
return 'Вчера';
}
return date('d.m.Y', $timestamp);
}
protected static function legacyRows($source, $limit)
{
$rows = self::readCsv($source, $limit);
+13 -1
View File
@@ -169,7 +169,6 @@
.events-hit-count {
min-width: 40px;
justify-content: center;
font-variant-numeric: tabular-nums;
}
.events-message {
font-weight: 600;
@@ -234,6 +233,19 @@
border: none;
min-height: 260px;
}
.events-table-day td {
padding: 9px 14px;
background: var(--background-muted);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.events-table-day span {
display: inline-flex;
align-items: center;
gap: 8px;
}
@media (max-width: 760px) {
.events-table-wrap {
max-height: none;
+5 -3
View File
@@ -1,5 +1,5 @@
/**
* JS раздела «Системные события»: раскрытие деталей и очистка legacy-логов.
* JS раздела «Системные события»: раскрытие деталей и очистка журналов.
*/
(function (window, document) {
'use strict';
@@ -45,8 +45,10 @@
var self = this;
Adminx.Confirm.open({
kind: 'error',
title: 'Очистить журнал?',
message: 'Все записи выбранного журнала будут удалены. В аудите останется только запись о самой очистке.',
title: source === 'audit' ? 'Очистить аудит?' : 'Очистить журнал?',
message: source === 'audit'
? 'Все записи аудита будут удалены. Останется только служебная запись о самой очистке.'
: 'Все записи выбранного журнала будут удалены. В аудите останется запись о самой очистке.',
confirmLabel: 'Очистить',
confirmClass: 'btn-danger',
onConfirm: function () {
@@ -3,7 +3,6 @@
<phrase data="auto_72aecd9ad85642d8">Error</phrase>
<phrase data="auto_7c839c52bcdfbc1e">Clear log?</phrase>
<phrase data="auto_98b2073ed1815f28">Clear</phrase>
<phrase data="auto_a75cb7ce578a8ded">All entries in the selected journal will be deleted. The audit will only contain a record of the cleaning itself.</phrase>
<phrase data="auto_c00aca49199296d7">Network error</phrase>
<phrase data="runtime_bf22dfeeba8b8508">Error</phrase>
</language>
@@ -3,7 +3,6 @@
<phrase data="auto_72aecd9ad85642d8">Ошибка</phrase>
<phrase data="auto_7c839c52bcdfbc1e">Очистить журнал?</phrase>
<phrase data="auto_98b2073ed1815f28">Очистить</phrase>
<phrase data="auto_a75cb7ce578a8ded">Все записи выбранного журнала будут удалены. В аудите останется только запись о самой очистке.</phrase>
<phrase data="auto_c00aca49199296d7">Ошибка сети</phrase>
<phrase data="runtime_bf22dfeeba8b8508">Ошибка</phrase>
</language>
+10 -3
View File
@@ -45,7 +45,7 @@
</div>
{% if active_source == 'legacy' %}
<div class="alert alert-info events-source-help"><i class="ti ti-info-circle alert-ic"></i><div><b>Что попадает в журнал приложения</b><p>Сюда выводятся служебные сообщения публичного runtime: результаты фоновых операций, предупреждения и записи старых интеграций. Действия администраторов находятся во вкладке «Аудит», SQL и 404 вынесены отдельно.</p></div></div>
<div class="alert alert-info events-source-help"><i class="ti ti-info-circle alert-ic"></i><div><b>Что попадает в журнал приложения</b><p>Сюда выводятся служебные сообщения публичного runtime: результаты фоновых операций, предупреждения и записи старых интеграций. Действия администраторов находятся в «Аудите», SQL и 404 вынесены отдельно.</p></div></div>
{% endif %}
<section class="tab-panel active">
@@ -78,8 +78,8 @@
<div class="events-section-title">
<span class="icon-tile events-head-icon" style="--tile-bg:{{ active_source_def.tile_bg }};--tile-fg:{{ active_source_def.tile_fg }}"><i class="{{ active_source_def.icon }}"></i></span>
<div>
<h2>{{ active_source == 'referrers' ? 'Журнал переходов' : 'Список событий' }}</h2>
<p class="text-secondary">{{ active_source == 'referrers' ? 'Сгруппированные внешние входы, источники и рекламные метки.' : 'Последние записи выбранного источника с фильтром и деталями.' }}</p>
<h2>{{ active_source == 'audit' ? 'Аудит действий' : (active_source == 'referrers' ? 'Журнал переходов' : 'Список событий') }}</h2>
<p class="text-secondary">{{ active_source == 'audit' ? 'Кто, когда и что изменил в системе.' : (active_source == 'referrers' ? 'Сгруппированные внешние входы, источники и рекламные метки.' : 'Последние записи выбранного источника с фильтром и деталями.') }}</p>
</div>
</div>
<div class="events-section-meta">
@@ -161,7 +161,14 @@
</tr>
</thead>
<tbody>
{% set active_day = '' %}
{% for row in rows %}
{% if active_source == 'audit' and active_day != row.date_group %}
{% set active_day = row.date_group %}
<tr class="events-table-day">
<td colspan="6"><span>{{ active_day }}</span></td>
</tr>
{% endif %}
<tr class="events-row {{ row.state_class }}">
<td class="mono text-muted">{{ row.time_display }}</td>
<td><span class="badge {{ row.state_badge }} events-state"><i class="{{ row.state_icon }}"></i>{{ row.state_label }}</span></td>
@@ -0,0 +1,33 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/GlobalSearch/Controller.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\GlobalSearch;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\Controller as BaseController;
use App\Helpers\Request;
class Controller extends BaseController
{
public function index(array $params = array())
{
$query = trim(Request::getStr('q', ''));
if (mb_strlen($query, 'UTF-8') < 2) {
return $this->success('', array('data' => array('items' => array())));
}
return $this->success('', array('data' => array('items' => Model::search($query, 30))));
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/GlobalSearch/Model.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\GlobalSearch;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\DatabaseSchema;
use App\Adminx\Support\GlobalSearchRegistry;
use App\Adminx\Support\ModuleExtensions;
use App\Content\ContentTables;
use App\Content\Documents\DocumentSearch;
use DB;
class Model
{
protected static $registered = false;
public static function search($query, $limit = 30)
{
self::registerProviders();
ModuleExtensions::boot();
return GlobalSearchRegistry::search($query, $limit);
}
public static function documents($query, $limit = 8)
{
$table = ContentTables::table('documents');
if (!self::available($table)) { return array(); }
$criteria = DocumentSearch::criteria($query, 'd');
$sql = 'SELECT d.Id,d.document_title,d.document_alias,d.document_status'
. ',(' . $criteria['score'] . ') AS search_relevance'
. ' FROM ' . $table . ' d'
. " WHERE d.document_deleted!='1' AND " . $criteria['where']
. ' ORDER BY search_relevance DESC,d.document_status DESC,d.document_changed DESC'
. ' LIMIT ' . max(1, min(12, (int) $limit));
$rows = call_user_func_array(
array('DB', 'query'),
array_merge(array($sql), $criteria['score_args'], $criteria['where_args'])
)->getAll() ?: array();
$out = array();
foreach ($rows as $row) {
$item = self::item(
'document',
'Документы',
self::decode($row['document_title']),
'#' . (int) $row['Id'] . ' · /' . (string) $row['document_alias'],
'/documents/' . (int) $row['Id'] . '/edit',
'ti-file-text'
);
$item['score'] = (float) $row['search_relevance'];
$out[] = $item;
}
return $out;
}
public static function rubrics($query, $limit = 6)
{
$table = ContentTables::table('rubrics');
if (!self::available($table)) { return array(); }
$rows = DB::query(
'SELECT Id,rubric_title,rubric_alias FROM ' . $table
. ' WHERE rubric_title LIKE %ss OR rubric_alias LIKE %ss OR Id=%i ORDER BY rubric_title LIMIT ' . max(1, min(12, (int) $limit)),
$query,
$query,
(int) $query
)->getAll() ?: array();
$out = array();
foreach ($rows as $row) {
$out[] = self::item('rubric', 'Рубрики', self::decode($row['rubric_title']), '#' . (int) $row['Id'] . ' · ' . (string) $row['rubric_alias'], '/rubrics?edit=' . (int) $row['Id'], 'ti-forms');
}
return $out;
}
public static function blocks($query, $limit = 6)
{
$table = ContentTables::table('sysblocks');
if (!self::available($table)) { return array(); }
$rows = DB::query(
'SELECT id,sysblock_name,sysblock_alias FROM ' . $table
. ' WHERE sysblock_name LIKE %ss OR sysblock_alias LIKE %ss OR id=%i ORDER BY sysblock_name LIMIT ' . max(1, min(12, (int) $limit)),
$query,
$query,
(int) $query
)->getAll() ?: array();
$out = array();
foreach ($rows as $row) {
$out[] = self::item('block', 'Блоки', self::decode($row['sysblock_name']), '#' . (int) $row['id'] . ' · ' . (string) $row['sysblock_alias'], '/blocks?edit=' . (int) $row['id'], 'ti-blockquote');
}
return $out;
}
public static function requests($query, $limit = 6)
{
$table = ContentTables::table('request');
if (!self::available($table)) { return array(); }
$rows = DB::query(
'SELECT Id,request_title,request_alias FROM ' . $table
. ' WHERE request_title LIKE %ss OR request_alias LIKE %ss OR Id=%i ORDER BY request_title LIMIT ' . max(1, min(12, (int) $limit)),
$query,
$query,
(int) $query
)->getAll() ?: array();
$out = array();
foreach ($rows as $row) {
$out[] = self::item('request', 'Запросы', self::decode($row['request_title']), '#' . (int) $row['Id'] . ' · ' . (string) $row['request_alias'], '/requests/' . (int) $row['Id'], 'ti-list-search');
}
return $out;
}
public static function navigation($query, $limit = 5)
{
$table = ContentTables::table('navigation_items');
if (!self::available($table)) { return array(); }
$rows = DB::query(
'SELECT navigation_item_id,navigation_id,title,alias FROM ' . $table
. ' WHERE title LIKE %ss OR alias LIKE %ss OR navigation_item_id=%i ORDER BY title LIMIT ' . max(1, min(12, (int) $limit)),
$query,
$query,
(int) $query
)->getAll() ?: array();
$out = array();
foreach ($rows as $row) {
$out[] = self::item(
'navigation',
'Навигация',
self::decode($row['title']),
'#' . (int) $row['navigation_item_id'] . ' · ' . (string) $row['alias'],
'/navigation?navigation=' . (int) $row['navigation_id'] . '&item=' . (int) $row['navigation_item_id'],
'ti-menu-2'
);
}
return $out;
}
public static function resetRuntime()
{
self::$registered = false;
GlobalSearchRegistry::resetRuntime();
}
protected static function registerProviders()
{
if (self::$registered) {
return;
}
self::$registered = true;
foreach (array(
'documents' => array('provider' => array(self::class, 'documents'), 'permission' => 'view_documents', 'priority' => 10, 'limit' => 8),
'rubrics' => array('provider' => array(self::class, 'rubrics'), 'permission' => 'view_rubrics', 'priority' => 20, 'limit' => 6),
'blocks' => array('provider' => array(self::class, 'blocks'), 'permission' => 'view_blocks', 'priority' => 30, 'limit' => 6),
'requests' => array('provider' => array(self::class, 'requests'), 'permission' => 'view_requests', 'priority' => 40, 'limit' => 6),
'navigation' => array('provider' => array(self::class, 'navigation'), 'permission' => 'view_navigation', 'priority' => 50, 'limit' => 5),
) as $code => $definition) {
GlobalSearchRegistry::register('core.' . $code, $definition);
}
}
protected static function item($type, $group, $title, $subtitle, $url, $icon)
{
return array(
'type' => (string) $type,
'group' => (string) $group,
'title' => (string) $title,
'subtitle' => (string) $subtitle,
'url' => (string) $url,
'icon' => (string) $icon,
);
}
protected static function available($table)
{
try { return DatabaseSchema::tableExists($table); }
catch (\Throwable $e) { return false; }
}
protected static function decode($value)
{
return html_entity_decode(stripcslashes((string) $value), ENT_QUOTES, 'UTF-8');
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/GlobalSearch/module.php
| @author AVE.cms <support@ave-cms.ru>
| @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' => 'global_search',
'name' => 'Глобальный поиск',
'version' => '0.2.0',
'routes' => array(
array('GET', '/search', array(\App\Adminx\GlobalSearch\Controller::class, 'index')),
),
);
@@ -10,7 +10,7 @@
<phrase data="auto_311e51e90722c580">Right</phrase>
<phrase data="auto_12c7f4e4dc957223">Everything is right</phrase>
<phrase data="auto_5b19b10f7cfa9da2">Rights</phrase>
<phrase data="auto_f55f990ad542ffbd">Rights are not synchronized. Run</phrase>
<phrase data="auto_f55f990ad542ffbd">Permissions are not synchronized. Update the schema in Database → Migrations.</phrase>
<phrase data="auto_a5cd7a935114cdd8">Raleigh</phrase>
<phrase data="auto_1ada279ae1fdd9f0">roles · assignment of access rights to sections</phrase>
<phrase data="auto_afb1d0899bb631ca">There are no roles - run setup-rbac</phrase>
@@ -10,7 +10,7 @@
<phrase data="auto_311e51e90722c580">Прав</phrase>
<phrase data="auto_12c7f4e4dc957223">Прав всего</phrase>
<phrase data="auto_5b19b10f7cfa9da2">Права</phrase>
<phrase data="auto_f55f990ad542ffbd">Права не синхронизированы. Запустите</phrase>
<phrase data="auto_f55f990ad542ffbd">Права не синхронизированы. Обновите схему в разделе «База данных → Миграции».</phrase>
<phrase data="auto_a5cd7a935114cdd8">Ролей</phrase>
<phrase data="auto_1ada279ae1fdd9f0">ролей · назначение прав доступа по разделам</phrase>
<phrase data="auto_afb1d0899bb631ca">Ролей нет — запустите setup-rbac</phrase>
@@ -0,0 +1,5 @@
UPDATE `{{prefix}}_permissions`
SET
`name` = 'Доступ в панель управления',
`description` = 'Разрешает вход в панель управления.'
WHERE `code` = 'admin_panel';
+5 -1
View File
@@ -21,7 +21,7 @@
return [
'code' => 'groups',
'name' => 'Роли и права',
'version' => '0.1.0',
'version' => '0.1.1',
'permissions' => [
'key' => 'groups',
@@ -89,6 +89,10 @@
'id' => '005_register_development_site_permission',
'file' => 'migrations/005_register_development_site_permission.sql',
],
[
'id' => '006_normalize_control_panel_permission',
'file' => 'migrations/006_normalize_control_panel_permission.sql',
],
],
'view_globals' => [
+1 -1
View File
@@ -137,7 +137,7 @@
</div>
</div>
{% else %}
<div class="text-muted text-sm">Права не синхронизированы. Запустите <code>php adminx/tools/setup-rbac.php</code>.</div>
<div class="text-muted text-sm">Права не синхронизированы. Обновите схему в разделе «База данных → Миграции».</div>
{% endfor %}
</div>
</div>
+4 -4
View File
@@ -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,
);
}
+101 -30
View File
@@ -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)
+15 -8
View File
@@ -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;
+2 -2
View File
@@ -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 () {
+6 -6
View File
@@ -16,7 +16,7 @@
{% if can_manage %}
<div class="cluster">
<button class="btn btn-secondary" type="button" data-media-folder><i class="ti ti-folder-plus"></i>Папка</button>
<button class="btn btn-danger-soft" type="button" data-media-clear-thumbs data-path="{{ dir }}" data-name="{{ dir }}" data-tooltip="Удалить все превью в текущей папке"><i class="ti ti-photo-off"></i>Очистить превью</button>
<button class="btn btn-danger-soft" type="button" data-media-clear-thumbs data-path="{{ dir }}" data-name="{{ dir }}" data-tooltip="Удалить превью в текущей и вложенных папках"><i class="ti ti-photo-off"></i>Очистить превью</button>
<button class="btn btn-primary" type="button" data-media-upload-trigger><i class="ti ti-upload"></i>Загрузить</button>
</div>
{% endif %}
@@ -33,7 +33,7 @@
<div><b>{{ stats.images }}</b><span>Изображений</span></div>
</div>
<div class="card media-stat">
<span class="icon-tile" style="--tile-bg:#fff7ed;--tile-fg:#ea580c"><i class="ti ti-folders"></i></span>
<span class="icon-tile" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-folders"></i></span>
<div><b>{{ stats.folders }}</b><span>Папок</span></div>
</div>
<div class="card media-stat">
@@ -131,10 +131,10 @@
<article class="media-tile is-folder">
<a class="media-thumb" href="{{ ADMINX_BASE }}/media?dir={{ folder.path|url_encode }}&view=grid&q={{ filters.q|url_encode }}&type={{ filters.type }}"><i class="ti ti-folder"></i></a>
<a class="media-name" href="{{ ADMINX_BASE }}/media?dir={{ folder.path|url_encode }}&view=grid&q={{ filters.q|url_encode }}&type={{ filters.type }}">{{ folder.name }}</a>
<small>{{ folder.count }} объектов</small>
<small>{% if filters.q %}{{ folder.path }} · {% endif %}{{ folder.count }} объектов</small>
{% if can_manage %}
<div class="media-actions">
<button class="btn btn-ghost btn-icon btn-sm media-action media-action-thumbs" type="button" data-media-clear-thumbs data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-tooltip="Очистить превью" aria-label="Очистить превью"><i class="ti ti-photo-off"></i></button>
<button class="btn btn-ghost btn-icon btn-sm media-action media-action-thumbs" type="button" data-media-clear-thumbs data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-tooltip="Очистить превью во вложенных папках" aria-label="Очистить превью"><i class="ti ti-photo-off"></i></button>
<button class="btn btn-ghost btn-icon btn-sm media-action media-action-edit" type="button" data-media-rename data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-kind="folder" data-tooltip="Переименовать" aria-label="Переименовать"><i class="ti ti-pencil"></i></button>
<button class="btn btn-ghost btn-icon btn-sm media-action media-action-danger" type="button" data-media-delete data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-kind="folder" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button>
</div>
@@ -179,8 +179,8 @@
{% for folder in folders %}
<tr>
<td><a class="media-row-name" href="{{ ADMINX_BASE }}/media?dir={{ folder.path|url_encode }}&view=table&q={{ filters.q|url_encode }}&type={{ filters.type }}"><span class="media-row-icon is-folder"><i class="ti ti-folder"></i></span><b>{{ folder.name }}</b></a></td>
<td>Папка</td><td>{{ folder.count }} объектов</td><td>-</td>
<td>{% if can_manage %}<div class="cluster media-row-actions"><button class="btn btn-ghost btn-icon btn-sm media-action media-action-thumbs" type="button" data-media-clear-thumbs data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-tooltip="Очистить превью" aria-label="Очистить превью"><i class="ti ti-photo-off"></i></button><button class="btn btn-ghost btn-icon btn-sm media-action media-action-edit" type="button" data-media-rename data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-kind="folder" data-tooltip="Переименовать" aria-label="Переименовать"><i class="ti ti-pencil"></i></button><button class="btn btn-ghost btn-icon btn-sm media-action media-action-danger" type="button" data-media-delete data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-kind="folder" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button></div>{% endif %}</td>
<td>Папка</td><td>{% if filters.q %}<span class="media-folder-path">{{ folder.path }}</span>{% else %}{{ folder.count }} объектов{% endif %}</td><td>-</td>
<td>{% if can_manage %}<div class="cluster media-row-actions"><button class="btn btn-ghost btn-icon btn-sm media-action media-action-thumbs" type="button" data-media-clear-thumbs data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-tooltip="Очистить превью во вложенных папках" aria-label="Очистить превью"><i class="ti ti-photo-off"></i></button><button class="btn btn-ghost btn-icon btn-sm media-action media-action-edit" type="button" data-media-rename data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-kind="folder" data-tooltip="Переименовать" aria-label="Переименовать"><i class="ti ti-pencil"></i></button><button class="btn btn-ghost btn-icon btn-sm media-action media-action-danger" type="button" data-media-delete data-path="{{ folder.path }}" data-name="{{ folder.name }}" data-kind="folder" data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button></div>{% endif %}</td>
</tr>
{% endfor %}
{% for f in files %}
+1
View File
@@ -52,6 +52,7 @@
'repository' => $repository,
'repository_settings' => ModuleRepository::settings(),
'official_repository' => ModuleRepository::officialSettings(),
'focus_code' => strtolower(trim(Request::getStr('focus', ''))),
));
}
@@ -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'],
+4 -3
View File
@@ -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;
}
+21
View File
@@ -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 () {
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="auto_093a3bc2fa9b4629">Update module</phrase>
<phrase data="auto_0a5577462b8be085">The module, its data, settings, menus and rights will be deleted. This action is irreversible.</phrase>
<phrase data="auto_0aca097f8e8b02be">Restore module operation?</phrase>
<phrase data="auto_0bff2559ed01357f">Successful migrations will be skipped and the aborted step will be re-executed. Please check the given error before proceeding.</phrase>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="auto_093a3bc2fa9b4629">Обновить модуль</phrase>
<phrase data="auto_0a5577462b8be085">Модуль, его данные, настройки, меню и права будут удалены. Это действие необратимо.</phrase>
<phrase data="auto_0aca097f8e8b02be">Восстановить операцию модуля?</phrase>
<phrase data="auto_0bff2559ed01357f">Успешные миграции будут пропущены, а оборванный этап выполнится повторно. Перед продолжением проверьте указанную ошибку.</phrase>
+1 -3
View File
@@ -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(
+5 -5
View File
@@ -80,7 +80,7 @@
<tbody>
{% for module in modules %}
{% set state = module.error ? 'error' : (module.recoverable ? 'recovery' : (not module.installed ? 'available' : (module.needs_update ? 'update' : (module.enabled ? 'active' : 'inactive')))) %}
<tr class="modules-scope-{{ module.scope }}" data-module-row data-code="{{ module.code }}" data-name="{{ module.name|lower|e('html_attr') }}" data-state="{{ state }}" data-scope="{{ module.scope }}" data-search="{{ (module.name ~ ' ' ~ module.code ~ ' ' ~ module.function ~ ' ' ~ module.scope)|lower }}">
<tr class="modules-scope-{{ module.scope }}{{ focus_code == module.code ? ' is-focused' : '' }}" data-module-row data-code="{{ module.code }}" data-name="{{ module.name|lower|e('html_attr') }}" data-state="{{ state }}" data-scope="{{ module.scope }}" data-search="{{ (module.name ~ ' ' ~ module.code ~ ' ' ~ module.function ~ ' ' ~ module.scope)|lower }}">
<td>
<div class="modules-identity">
<span class="modules-icon"><i class="{{ module.icon|default('ti ti-puzzle') }}"></i></span>
@@ -97,7 +97,7 @@
{% if module.error %}<span class="badge badge-red badge-dot">Ошибка</span>
{% elseif module.recoverable %}<span class="badge badge-red badge-dot">Восстановление</span>
{% elseif not module.installed %}<span class="badge badge-gray badge-dot">Не установлен</span>
{% elseif module.needs_update %}<span class="badge badge-orange badge-dot">{{ module.migration_pending ? 'Миграции' : 'Обновление' }}</span>
{% elseif module.needs_update %}<span class="badge badge-orange badge-dot">Миграции</span>
{% elseif module.enabled %}<span class="badge badge-green badge-dot">Активен</span>
{% else %}<span class="badge badge-gray badge-dot">Отключён</span>{% endif %}
</td>
@@ -108,7 +108,7 @@
</td>
<td class="modules-version">
<b class="mono" data-tooltip="Версия файлов">Файлы: {{ module.file_version ?: '—' }}</b>
{% if module.installed and module.db_version != module.file_version %}<span class="text-muted text-xs mono">БД: {{ module.db_version ?: '—' }}</span>{% endif %}
{% if module.migration_pending %}<span class="text-muted text-xs mono">Установлено: {{ module.db_version ?: '—' }}</span>{% endif %}
</td>
<td>
{% if module.admin_url or can_manage %}
@@ -127,7 +127,7 @@
{% if module.removable %}<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-module-action="remove" data-tooltip="Удалить файлы" aria-label="Удалить файлы"><i class="ti ti-folder-x"></i></button>{% endif %}
{% elseif module.installed %}
<label class="switch modules-switch" data-tooltip="{{ module.enabled ? 'Отключить' : 'Включить' }}"><input type="checkbox" data-module-toggle{{ module.enabled ? ' checked' : '' }}><span></span></label>
{% if module.needs_update %}<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-ok" type="button" data-module-action="update" data-tooltip="{{ module.migration_pending ? 'Применить миграции' : 'Обновить модуль' }}" aria-label="{{ module.migration_pending ? 'Применить миграции' : 'Обновить модуль' }}"><i class="ti {{ module.migration_pending ? 'ti-database-import' : 'ti-refresh' }}"></i></button>{% endif %}
{% if module.needs_update %}<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-ok" type="button" data-module-action="update" data-tooltip="Применить миграции" aria-label="Применить миграции"><i class="ti ti-database-import"></i></button>{% endif %}
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-view" type="button" data-module-action="reinstall" data-tooltip="Переустановить" aria-label="Переустановить"><i class="ti ti-reload"></i></button>
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-module-action="uninstall" data-tooltip="Деинсталлировать" aria-label="Деинсталлировать"><i class="ti ti-trash"></i></button>
{% endif %}
@@ -191,7 +191,7 @@
<thead><tr><th>Модуль</th><th style="width:154px">Область</th><th style="width:170px">Версия</th><th style="width:150px">Состояние</th><th style="width:76px"></th></tr></thead>
<tbody>
{% for module in repository.items %}
<tr data-repository-row data-code="{{ module.code }}" data-name="{{ module.name|lower|e('html_attr') }}">
<tr class="{{ focus_code == module.code ? 'is-focused' : '' }}" data-repository-row data-code="{{ module.code }}" data-name="{{ module.name|lower|e('html_attr') }}">
<td><div class="modules-identity"><span class="modules-icon"><i class="ti ti-package"></i></span><span class="modules-copy"><span class="modules-title">{{ module.name }}</span><span class="modules-meta"><span class="mono">{{ module.code }}</span>{% if module.author %}<span>{{ module.author }}</span>{% endif %}</span>{% if module.description %}<span class="modules-description">{{ module.description }}</span>{% endif %}</span></div></td>
<td>{% if module.kind == 'admin' %}<span class="badge modules-scope-badge is-admin"><i class="ti ti-settings"></i>Админка</span>{% else %}<span class="badge modules-scope-badge is-mixed"><i class="ti ti-arrows-exchange"></i>Паблик + админка</span>{% endif %}</td>
<td class="modules-version"><b class="mono">Каталог: {{ module.version }}</b>{% if module.local_version %}<span class="text-muted text-xs mono">Файлы: {{ module.local_version }}</span>{% endif %}{% if module.migration_pending %}<span class="text-muted text-xs mono">БД: {{ module.database_version ?: '—' }}</span>{% endif %}</td>
+11 -29
View File
@@ -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);
@@ -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;
@@ -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);
}
+1 -1
View File
@@ -49,7 +49,7 @@
'icon' => 'ti ti-sitemap',
'permission' => 'view_navigation',
'group' => 'Контент',
'sort_order' => 25,
'sort_order' => 26,
'match' => array(
'/navigation',
),
+164
View File
@@ -0,0 +1,164 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/PublicSite/Controller.php
| @author AVE.cms <support@ave-cms.ru>
| @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();
}
}
+498
View File
@@ -0,0 +1,498 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/PublicSite/Model.php
| @author AVE.cms <support@ave-cms.ru>
| @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);
}
}
@@ -0,0 +1,271 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/PublicSite/PresentationModel.php
| @author AVE.cms <support@ave-cms.ru>
| @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');
}
}
@@ -0,0 +1,279 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/PublicSite/PresentationsController.php
| @author AVE.cms <support@ave-cms.ru>
| @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 = '<!doctype html><html><head><meta charset="utf-8">'
. '<meta name="viewport" content="width=device-width,initial-scale=1">'
. '<style>html,body{margin:0;min-height:100%;background:#f5f7fa;color:#172033;font:14px/1.5 Arial,sans-serif}'
. 'body{padding:24px}*{box-sizing:border-box}a{color:#2563eb}</style>'
. ($css !== '' ? '<style>' . $css . '</style>' : '')
. '</head><body><main>' . $result['markup'] . '</main></body></html>';
} 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' => '<article class="content-card">' . "\n"
. ' <h2><a href="{{ item.url }}">{{ item.title }}</a></h2>' . "\n"
. ' {% if item.excerpt %}<p>{{ item.excerpt }}</p>{% endif %}' . "\n"
. '</article>',
'wrapper' => '<div class="content-list">' . "\n" . ' {{ content|raw }}' . "\n" . '</div>',
'empty' => '<div class="empty-state">Материалы не найдены.</div>',
);
}
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,
));
}
}
@@ -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%;
}
}
@@ -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 = '<div class="empty-state">Назначений пока нет.</div>';
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 = '<span class="public-presentation-assignment-target' + targetClass + '"><b>' + self.escape(item.target_title || item.target_key) + '</b><small>' + self.escape(item.target_meta || '') + '</small></span>';
if (item.target_url) {
target = '<a class="public-presentation-assignment-target" href="' + self.escape(self.base() + item.target_url) + '"><b>' + self.escape(item.target_title || item.target_key) + '</b><small>' + self.escape(item.target_meta || '') + '</small></a>';
}
return '<div class="public-presentation-assignment"><span><b>' + self.escape(contexts[item.context_code] || item.context_code) + '</b><small>' + self.escape(item.updated_label || '') + '</small></span>' + target + '<span class="badge ' + modeClass + '">' + self.escape(item.mode_label || item.mode) + '</span><button class="btn btn-ghost btn-icon btn-sm is-danger" type="button" data-presentation-assignment-delete="' + Number(item.id) + '" aria-label="Удалить"><i class="ti ti-trash"></i></button></div>';
}).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 '<option value="' + self.escape(item.key) + '">' + self.escape(item.title) + ' · ' + self.escape(item.meta) + '</option>';
}).join('');
if (!items.length) {
select.innerHTML = '<option value="">Доступных целей нет</option>';
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 ? '<a class="btn btn-ghost btn-sm" href="' + self.escape(self.base() + check.url) + '"><i class="ti ti-arrow-up-right"></i>Открыть</a>' : '';
return '<article class="public-presentation-diagnostic is-' + self.escape(check.status) + '"><i class="ti ' + icon + '"></i><span><b>' + self.escape(check.title) + '</b><small>' + self.escape(check.message) + '</small></span>' + action + '</article>';
}).join('') || '<div class="empty-state">Результатов проверки нет.</div>';
},
clearDiagnostics: function () {
this.setDiagnosticState('Проверка ещё не запускалась', '');
var list = this.drawer.querySelector('[data-presentation-diagnostic-list]');
if (list) { list.innerHTML = '<div class="empty-state">Сохраните представление и запустите диагностику.</div>'; }
},
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 = '<span class="badge ' + badge + '">' + this.escape(message || '') + '</span>';
},
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 = '<div class="empty-state">Сначала сохраните представление.</div>';
return;
}
list.innerHTML = '<div class="empty-state">Загрузка...</div>';
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 '<button type="button" data-presentation-revision="' + Number(item.id) + '"><span><b>' + self.escape(item.action_label) + '</b><small>' + self.escape(item.comment || item.author_name || '') + '</small></span><span><em class="badge ' + self.escape(item.badge) + '">' + self.escape(item.action_label) + '</em><small class="mono">' + self.escape(item.created_label) + '</small></span></button>';
}).join('') : '<div class="empty-state">Ревизий пока нет.</div>';
}).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 = '<b>' + self.escape(item.action_label || '') + '</b><small>' + self.escape(item.created_label || '') + ' · ' + self.escape(item.author_name || 'system') + '</small>';
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);
@@ -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;
}
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="public_site_catalog_filters">Catalog filters</phrase>
<phrase data="public_site_choose_material">Choose a material</phrase>
<phrase data="public_site_choose_snapshot">Choose a snapshot above.</phrase>
<phrase data="public_site_compact_content_list">Compact list</phrase>
<phrase data="public_site_compact_list">Compact list</phrase>
<phrase data="public_site_content_list">Material list</phrase>
<phrase data="public_site_diag_failed_state">failed</phrase>
<phrase data="public_site_diag_passed">passed</phrase>
<phrase data="public_site_diag_results">Inspection results</phrase>
<phrase data="public_site_document_page">Material page</phrase>
<phrase data="public_site_favorite">Favorites</phrase>
<phrase data="public_site_items_lower">materials</phrase>
<phrase data="public_site_new_presentation">New presentation</phrase>
<phrase data="public_site_no_snapshots">No snapshots yet.</phrase>
<phrase data="public_site_preview_data_hint">Data available to the template will appear here after preview.</phrase>
<phrase data="public_site_related_items">Related materials</phrase>
<phrase data="public_site_search_results">Search results</phrase>
<phrase data="public_site_version_lower">version</phrase>
</language>
@@ -0,0 +1,215 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="nav_public_site">Public site</phrase>
<phrase data="public_site_permission">Public site: view structure</phrase>
<phrase data="public_site_permission_description">View links between content types, documents, requests, catalogs and templates.</phrase>
<phrase data="public_site_intro">A map of content, collections, catalogs and presentation links.</phrase>
<phrase data="public_site_content_types">Content types</phrase>
<phrase data="public_site_materials">Materials</phrase>
<phrase data="public_site_collections">Collections</phrase>
<phrase data="public_site_catalogs">Catalogs</phrase>
<phrase data="public_site_components">Blocks and menus</phrase>
<phrase data="public_site_product_presentations">Product presentations</phrase>
<phrase data="public_site_stage_one">First stage</phrase>
<phrase data="public_site_structure_map">Structure map</phrase>
<phrase data="public_site_safe_map">Nothing is switched on the public site: this map shows existing entities and links to their standard editors.</phrase>
<phrase data="public_site_content_types_title">Content types</phrase>
<phrase data="public_site_content_type">Content type</phrase>
<phrase data="public_site_current_list">in the current list</phrase>
<phrase data="public_site_open_rubrics">Open content types</phrase>
<phrase data="public_site_search_placeholder">Title, alias or ID</phrase>
<phrase data="public_site_show">Show</phrase>
<phrase data="public_site_reset">Reset</phrase>
<phrase data="public_site_site_template">Site template</phrase>
<phrase data="public_site_composition">Composition</phrase>
<phrase data="public_site_state">State</phrase>
<phrase data="public_site_actions">Actions</phrase>
<phrase data="public_site_alias_missing">alias is not set</phrase>
<phrase data="public_site_not_assigned">not assigned</phrase>
<phrase data="public_site_items_lower">materials</phrase>
<phrase data="public_site_fields_lower">fields</phrase>
<phrase data="public_site_collections_lower">collections</phrase>
<phrase data="public_site_catalogs_lower">catalogs</phrase>
<phrase data="public_site_catalogs_link">Catalogs</phrase>
<phrase data="public_site_creation_disabled">creation disabled</phrase>
<phrase data="public_site_configured">configured</phrase>
<phrase data="public_site_content_link">Materials</phrase>
<phrase data="public_site_type_settings">Content type settings</phrase>
<phrase data="public_site_not_found">No content types found.</phrase>
<phrase data="public_site_diagnostics">Diagnostics</phrase>
<phrase data="public_site_hints_only">Hints only, without automatic changes</phrase>
<phrase data="public_site_no_issues">No obvious problems were found on the current map.</phrase>
<phrase data="public_site_preview_renderers">Collection preview</phrase>
<phrase data="public_site_preview_description">Ways to verify results without changing the public site</phrase>
<phrase data="public_site_unavailable">unavailable</phrase>
<phrase data="public_site_presentation_data">Presentation data</phrase>
<phrase data="public_site_palette">Shared palette provided by the core and enabled modules</phrase>
<phrase data="public_site_classic_editors">Classic editors</phrase>
<phrase data="public_site_classic_remain">Familiar sections remain the primary editors</phrase>
<phrase data="public_site_requests">Requests and conditions</phrase>
<phrase data="public_site_blocks">Blocks</phrase>
<phrase data="public_site_reusable_fragments">Reusable fragments</phrase>
<phrase data="public_site_navigation">Navigation</phrase>
<phrase data="public_site_menu_tree">Menus and item tree</phrase>
<phrase data="public_site_sections_filters">Sections and filters</phrase>
<phrase data="public_site_missing_request_item">The collection item template is empty</phrase>
<phrase data="public_site_missing_site_template">An existing site template is not assigned</phrase>
<phrase data="public_site_empty_rubric_template">The main content type template is empty</phrase>
<phrase data="public_site_no_fields">The content type has no fields</phrase>
<phrase data="public_site_material_group">Material</phrase>
<phrase data="public_site_context_group">Context</phrase>
<phrase data="public_site_product_group">Product</phrase>
<phrase data="public_site_actions_group">Actions</phrase>
<phrase data="public_site_document_id">Material ID</phrase>
<phrase data="public_site_title">Title</phrase>
<phrase data="public_site_url">URL</phrase>
<phrase data="public_site_excerpt">Excerpt</phrase>
<phrase data="public_site_content_fields">Content type fields</phrase>
<phrase data="public_site_context_code">Usage context</phrase>
<phrase data="public_site_context_position">List position</phrase>
<phrase data="public_site_article">Article</phrase>
<phrase data="public_site_price">Price</phrase>
<phrase data="public_site_old_price">Old price</phrase>
<phrase data="public_site_stock">Stock</phrase>
<phrase data="public_site_images">Images</phrase>
<phrase data="public_site_badges">Badges</phrase>
<phrase data="public_site_variants">Variants</phrase>
<phrase data="public_site_shipping_packages">Shipping packages</phrase>
<phrase data="public_site_add_to_cart">Add to cart</phrase>
<phrase data="public_site_favorite">Favorites</phrase>
<phrase data="public_site_compare">Compare</phrase>
<phrase data="public_site_legacy_description">Current main/item templates and compatible public output.</phrase>
<phrase data="public_site_data_cards">Data cards</phrase>
<phrase data="public_site_data_cards_description">Readable preview of properties from the result contract.</phrase>
<phrase data="public_site_compact_list">Compact list</phrase>
<phrase data="public_site_compact_list_description">Title and primary values on one line.</phrase>
<phrase data="public_site_table">Table</phrase>
<phrase data="public_site_table_description">Materials in rows with selected data in columns.</phrase>
<phrase data="public_site_json_description">Structured result for APIs and integrations.</phrase>
<phrase data="public_site_help_title">Public site</phrase>
<phrase data="public_site_help_description">Shows links between content types, materials, collections, catalogs and presentations in one place.</phrase>
<phrase data="public_site_help_map">The map does not switch anything on the public site and links to existing editors.</phrase>
<phrase data="public_site_help_attributes">Content type fields remain universal content storage; attributes supplement them for filters and variants.</phrase>
<phrase data="public_site_help_diagnostics">Diagnostics only reports clear gaps in required templates.</phrase>
<phrase data="public_site_help_note">Classic sections remain a complete way to configure the site.</phrase>
<phrase data="public_site_sections_label">Public site sections</phrase>
<phrase data="public_site_presentations">Presentations</phrase>
<phrase data="public_site_presentations_intro">Reusable presentation for materials, collections and catalogs.</phrase>
<phrase data="public_site_new_presentation">New presentation</phrase>
<phrase data="public_site_storage_missing">Storage has not been created yet</phrase>
<phrase data="public_site_apply_migration">Apply the Public site section migration.</phrase>
<phrase data="public_site_content_presentation">Content presentation</phrase>
<phrase data="public_site_shared_presentations">Shared presentations</phrase>
<phrase data="public_site_draft_safe">Draft and preview do not change the current public site.</phrase>
<phrase data="public_site_presentation_kind">Presentation kind</phrase>
<phrase data="public_site_all_kinds">All kinds</phrase>
<phrase data="public_site_unpublished">Not published</phrase>
<phrase data="public_site_version_lower">version</phrase>
<phrase data="public_site_no_presentations">No presentations yet.</phrase>
<phrase data="public_site_compatibility">Compatibility</phrase>
<phrase data="public_site_current_editors">Current editors</phrase>
<phrase data="public_site_current_editors_hint">They keep managing the current output and are not copied automatically.</phrase>
<phrase data="public_site_ctrl_save">Ctrl/Cmd+S saves the draft and keeps the editor open.</phrase>
<phrase data="public_site_stable_assignment_name">Stable name used by assignments.</phrase>
<phrase data="public_site_purpose_example">For example: a material card in the news list</phrase>
<phrase data="public_site_presentation_editor">Presentation editor</phrase>
<phrase data="public_site_wrapper">Wrapper</phrase>
<phrase data="public_site_empty_result">Empty result</phrase>
<phrase data="public_site_assignments">Assignments</phrase>
<phrase data="public_site_item_template">Item template</phrase>
<phrase data="public_site_item_available">One material is available as item.</phrase>
<phrase data="public_site_list_wrapper">List wrapper</phrase>
<phrase data="public_site_wrapper_hint">Rendered elements are available in content and source data in items.</phrase>
<phrase data="public_site_empty_hint">Shown when the collection returns no materials.</phrase>
<phrase data="public_site_presentation_css">Presentation CSS</phrase>
<phrase data="public_site_css_hint">Styles apply only to this presentation.</phrase>
<phrase data="public_site_available_data">Available data</phrase>
<phrase data="public_site_insert_data_hint">Click a value to insert it into the item template.</phrase>
<phrase data="public_site_real_preview">Preview with a real material</phrase>
<phrase data="public_site_preview_safe">Only the draft is used; the public site does not change.</phrase>
<phrase data="public_site_choose_material">Choose a material</phrase>
<phrase data="public_site_preview_data_hint">Data available to the template will appear here after preview.</phrase>
<phrase data="public_site_where_to_use">Where to use</phrase>
<phrase data="public_site_legacy_mode_hint">Current output mode only records the link and switches nothing.</phrase>
<phrase data="public_site_content_list">Material list</phrase>
<phrase data="public_site_compact_content_list">Compact list</phrase>
<phrase data="public_site_search_results">Search results</phrase>
<phrase data="public_site_related_items">Related materials</phrase>
<phrase data="public_site_catalog_filters">Catalog filters</phrase>
<phrase data="public_site_document_page">Material page</phrase>
<phrase data="public_site_apply_to">Apply to</phrase>
<phrase data="public_site_target_code">Target ID or code</phrase>
<phrase data="public_site_assign">Assign</phrase>
<phrase data="public_site_change_history">Change history</phrase>
<phrase data="public_site_no_snapshots">No snapshots yet.</phrase>
<phrase data="public_site_choose_snapshot">Choose a snapshot above.</phrase>
<phrase data="public_site_place">Place</phrase>
<phrase data="public_site_everywhere">Everywhere</phrase>
<phrase data="public_site_to_content_type">Content type</phrase>
<phrase data="public_site_to_collection">Collection</phrase>
<phrase data="public_site_to_catalog">Catalog</phrase>
<phrase data="public_site_to_module">Module</phrase>
<phrase data="public_site_diag_page_title">Public page diagnostics</phrase>
<phrase data="public_site_diag_page_intro">Shows how the page is assembled and why a document appears in a collection. Nothing is saved or switched.</phrase>
<phrase data="public_site_diag_page_context">Page context</phrase>
<phrase data="public_site_diag_page_composition">How the page is assembled</phrase>
<phrase data="public_site_diag_page_hint">Enter a public URL or document ID. The inspection reads saved data without executing the page.</phrase>
<phrase data="public_site_diag_inspect">Inspect page</phrase>
<phrase data="public_site_diag_failed">Page inspection failed</phrase>
<phrase data="public_site_diag_results">Inspection results</phrase>
<phrase data="public_site_diag_open_document">Open document</phrase>
<phrase data="public_site_diag_open_setting">Open setting</phrase>
<phrase data="public_site_diag_render_chain">Render chain</phrase>
<phrase data="public_site_diag_templates">Templates</phrase>
<phrase data="public_site_diag_characters">characters</phrase>
<phrase data="public_site_diag_document_cache">Document cache</phrase>
<phrase data="public_site_diag_full_page">Full page</phrase>
<phrase data="public_site_diag_document_template">Document template</phrase>
<phrase data="public_site_diag_not_created">not created</phrase>
<phrase data="public_site_diag_dependencies">Dependencies</phrase>
<phrase data="public_site_diag_connected_components">Connected components</phrase>
<phrase data="public_site_diag_components_hint">Blocks, collections, navigation, and modules found in the active template chain.</phrase>
<phrase data="public_site_diag_where_connected">Connected from</phrase>
<phrase data="public_site_diag_open_component">Open component</phrase>
<phrase data="public_site_diag_no_components">No components found in templates</phrase>
<phrase data="public_site_diag_document_schema">Document schema</phrase>
<phrase data="public_site_diag_fields_values">Fields and actual values</phrase>
<phrase data="public_site_diag_fields_hint">All category fields are shown, including empty and required fields.</phrase>
<phrase data="public_site_diag_field">Field</phrase>
<phrase data="public_site_diag_type">Type</phrase>
<phrase data="public_site_diag_value">Value</phrase>
<phrase data="public_site_diag_required_empty">required value is missing</phrase>
<phrase data="public_site_diag_empty">empty</phrase>
<phrase data="public_site_diag_filled">filled</phrase>
<phrase data="public_site_diag_no_fields">The category has no fields</phrase>
<phrase data="public_site_diag_rubric_collections">Category collections</phrase>
<phrase data="public_site_diag_no_alias">no alias</phrase>
<phrase data="public_site_diag_no_requests">No requests for this category</phrase>
<phrase data="public_site_diag_direct_module_hint">The document may be used directly or by a module.</phrase>
<phrase data="public_site_diag_result_design">Result presentation</phrase>
<phrase data="public_site_diag_no_presentations">No presentations assigned</phrase>
<phrase data="public_site_diag_standard_templates">The page uses standard AVE.cms templates.</phrase>
<phrase data="public_site_diag_structured_page">Structured page package</phrase>
<phrase data="public_site_diag_why_in_request">Why the document is in the request</phrase>
<phrase data="public_site_diag_parameters_hint">Parameters can be entered as</phrase>
<phrase data="public_site_diag_or_json">or a JSON object.</phrase>
<phrase data="public_site_diag_request_id">Request ID</phrase>
<phrase data="public_site_diag_document_id">Document ID</phrase>
<phrase data="public_site_diag_page_parameters">Page parameters</phrase>
<phrase data="public_site_diag_explain">Explain</phrase>
<phrase data="public_site_diag_request_failed">Request inspection failed</phrase>
<phrase data="public_site_diag_check_result">Inspection result</phrase>
<phrase data="public_site_diag_document_included">Document is included in the result</phrase>
<phrase data="public_site_diag_document_excluded">Document is excluded from the result</phrase>
<phrase data="public_site_diag_found">found</phrase>
<phrase data="public_site_diag_excluded">excluded</phrase>
<phrase data="public_site_diag_structured_request">Structured request explanation</phrase>
<phrase data="public_site_diag_passed">passed</phrase>
<phrase data="public_site_diag_failed_state">failed</phrase>
<phrase data="public_site_diag_skipped">skipped</phrase>
<phrase data="public_site_diag_no_value">no value</phrase>
<phrase data="public_site_diag_empty_template">empty</phrase>
<phrase data="public_site_diag_filled_template">filled</phrase>
<phrase data="public_site_diag_component">Component</phrase>
<phrase data="public_site_diag_tag">Tag</phrase>
</language>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="public_site_catalog_filters">Фильтры каталога</phrase>
<phrase data="public_site_choose_material">Выберите материал</phrase>
<phrase data="public_site_choose_snapshot">Выберите снимок выше.</phrase>
<phrase data="public_site_compact_content_list">Компактный список</phrase>
<phrase data="public_site_compact_list">Компактный список</phrase>
<phrase data="public_site_content_list">Список материалов</phrase>
<phrase data="public_site_diag_failed_state">не выполнена</phrase>
<phrase data="public_site_diag_passed">выполнена</phrase>
<phrase data="public_site_diag_results">Результатов проверки</phrase>
<phrase data="public_site_document_page">Страница материала</phrase>
<phrase data="public_site_favorite">Избранное</phrase>
<phrase data="public_site_items_lower">материалов</phrase>
<phrase data="public_site_new_presentation">Новое представление</phrase>
<phrase data="public_site_no_snapshots">Снимков пока нет.</phrase>
<phrase data="public_site_preview_data_hint">После запуска здесь появятся данные, доступные шаблону.</phrase>
<phrase data="public_site_related_items">Похожие материалы</phrase>
<phrase data="public_site_search_results">Результаты поиска</phrase>
<phrase data="public_site_version_lower">версия</phrase>
</language>
@@ -0,0 +1,215 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="nav_public_site">Публичный сайт</phrase>
<phrase data="public_site_permission">Публичный сайт: просмотр структуры</phrase>
<phrase data="public_site_permission_description">Просмотр связей рубрик, документов, запросов, каталогов и шаблонов.</phrase>
<phrase data="public_site_intro">Карта связей контента, подборок, каталогов и оформления.</phrase>
<phrase data="public_site_content_types">Типов контента</phrase>
<phrase data="public_site_materials">Материалов</phrase>
<phrase data="public_site_collections">Подборок</phrase>
<phrase data="public_site_catalogs">Каталогов</phrase>
<phrase data="public_site_components">Блоков и меню</phrase>
<phrase data="public_site_product_presentations">Товарных представлений</phrase>
<phrase data="public_site_stage_one">Первый этап</phrase>
<phrase data="public_site_structure_map">Карта структуры</phrase>
<phrase data="public_site_safe_map">Ничего не переключает в паблике: здесь видны существующие сущности и быстрые переходы в их штатные редакторы.</phrase>
<phrase data="public_site_content_types_title">Типы контента</phrase>
<phrase data="public_site_content_type">Тип контента</phrase>
<phrase data="public_site_current_list">в текущем списке</phrase>
<phrase data="public_site_open_rubrics">Открыть рубрики</phrase>
<phrase data="public_site_search_placeholder">Название, alias или ID</phrase>
<phrase data="public_site_show">Показать</phrase>
<phrase data="public_site_reset">Сбросить</phrase>
<phrase data="public_site_site_template">Шаблон сайта</phrase>
<phrase data="public_site_composition">Состав</phrase>
<phrase data="public_site_state">Состояние</phrase>
<phrase data="public_site_actions">Действия</phrase>
<phrase data="public_site_alias_missing">alias не задан</phrase>
<phrase data="public_site_not_assigned">не назначен</phrase>
<phrase data="public_site_items_lower">материалов</phrase>
<phrase data="public_site_fields_lower">полей</phrase>
<phrase data="public_site_collections_lower">подборок</phrase>
<phrase data="public_site_catalogs_lower">каталогов</phrase>
<phrase data="public_site_catalogs_link">Каталоги</phrase>
<phrase data="public_site_creation_disabled">создание выключено</phrase>
<phrase data="public_site_configured">настроено</phrase>
<phrase data="public_site_content_link">Материалы</phrase>
<phrase data="public_site_type_settings">Настройки типа</phrase>
<phrase data="public_site_not_found">Типы контента не найдены.</phrase>
<phrase data="public_site_diagnostics">Диагностика</phrase>
<phrase data="public_site_hints_only">Только подсказки, без автоматических изменений</phrase>
<phrase data="public_site_no_issues">Явных проблем в текущей карте нет.</phrase>
<phrase data="public_site_preview_renderers">Предпросмотр подборок</phrase>
<phrase data="public_site_preview_description">Способы проверки результата без изменения публичного сайта</phrase>
<phrase data="public_site_unavailable">недоступен</phrase>
<phrase data="public_site_presentation_data">Данные представлений</phrase>
<phrase data="public_site_palette">Общая палитра ядра и включённых модулей</phrase>
<phrase data="public_site_classic_editors">Классические редакторы</phrase>
<phrase data="public_site_classic_remain">Привычные разделы остаются основными</phrase>
<phrase data="public_site_requests">Запросы и условия</phrase>
<phrase data="public_site_blocks">Блоки</phrase>
<phrase data="public_site_reusable_fragments">Повторяемые фрагменты</phrase>
<phrase data="public_site_navigation">Навигация</phrase>
<phrase data="public_site_menu_tree">Меню и дерево пунктов</phrase>
<phrase data="public_site_sections_filters">Разделы и фильтры</phrase>
<phrase data="public_site_missing_request_item">У подборки не заполнен шаблон элемента</phrase>
<phrase data="public_site_missing_site_template">Не назначен существующий шаблон сайта</phrase>
<phrase data="public_site_empty_rubric_template">Пустой основной шаблон рубрики</phrase>
<phrase data="public_site_no_fields">У типа контента нет полей</phrase>
<phrase data="public_site_material_group">Материал</phrase>
<phrase data="public_site_context_group">Контекст</phrase>
<phrase data="public_site_product_group">Товар</phrase>
<phrase data="public_site_actions_group">Действия</phrase>
<phrase data="public_site_document_id">ID материала</phrase>
<phrase data="public_site_title">Название</phrase>
<phrase data="public_site_url">Адрес</phrase>
<phrase data="public_site_excerpt">Анонс</phrase>
<phrase data="public_site_content_fields">Поля типа контента</phrase>
<phrase data="public_site_context_code">Место использования</phrase>
<phrase data="public_site_context_position">Позиция в списке</phrase>
<phrase data="public_site_article">Артикул</phrase>
<phrase data="public_site_price">Цена</phrase>
<phrase data="public_site_old_price">Старая цена</phrase>
<phrase data="public_site_stock">Остаток</phrase>
<phrase data="public_site_images">Изображения</phrase>
<phrase data="public_site_badges">Метки</phrase>
<phrase data="public_site_variants">Варианты</phrase>
<phrase data="public_site_shipping_packages">Грузовые места</phrase>
<phrase data="public_site_add_to_cart">Добавить в корзину</phrase>
<phrase data="public_site_favorite">Избранное</phrase>
<phrase data="public_site_compare">Сравнение</phrase>
<phrase data="public_site_legacy_description">Текущие шаблоны main/item и совместимый публичный вывод.</phrase>
<phrase data="public_site_data_cards">Карточки данных</phrase>
<phrase data="public_site_data_cards_description">Читаемый предпросмотр свойств из контракта результата.</phrase>
<phrase data="public_site_compact_list">Компактный список</phrase>
<phrase data="public_site_compact_list_description">Название и основные значения в одной строке.</phrase>
<phrase data="public_site_table">Таблица</phrase>
<phrase data="public_site_table_description">Материалы по строкам, выбранные данные по колонкам.</phrase>
<phrase data="public_site_json_description">Структурированный результат для API и интеграций.</phrase>
<phrase data="public_site_help_title">Публичный сайт</phrase>
<phrase data="public_site_help_description">Показывает связи типов контента, материалов, подборок, каталогов и представлений в одном месте.</phrase>
<phrase data="public_site_help_map">Карта ничего не переключает в публичной части и ведёт в существующие редакторы.</phrase>
<phrase data="public_site_help_attributes">Поля рубрик остаются универсальным хранилищем контента; атрибуты дополняют их для фильтров и вариантов.</phrase>
<phrase data="public_site_help_diagnostics">Диагностика сообщает только о явных пробелах в обязательных шаблонах.</phrase>
<phrase data="public_site_help_note">Классические разделы остаются полноценным способом настройки сайта.</phrase>
<phrase data="public_site_sections_label">Разделы публичного сайта</phrase>
<phrase data="public_site_presentations">Представления</phrase>
<phrase data="public_site_presentations_intro">Повторно используемое оформление материалов, подборок и каталогов.</phrase>
<phrase data="public_site_new_presentation">Новое представление</phrase>
<phrase data="public_site_storage_missing">Хранилище ещё не создано</phrase>
<phrase data="public_site_apply_migration">Примените миграцию раздела «Публичный сайт».</phrase>
<phrase data="public_site_content_presentation">Оформление контента</phrase>
<phrase data="public_site_shared_presentations">Общие представления</phrase>
<phrase data="public_site_draft_safe">Черновик и предпросмотр не меняют действующий публичный сайт.</phrase>
<phrase data="public_site_presentation_kind">Вид представления</phrase>
<phrase data="public_site_all_kinds">Все виды</phrase>
<phrase data="public_site_unpublished">Не опубликованные</phrase>
<phrase data="public_site_version_lower">версия</phrase>
<phrase data="public_site_no_presentations">Представлений пока нет.</phrase>
<phrase data="public_site_compatibility">Совместимость</phrase>
<phrase data="public_site_current_editors">Действующие редакторы</phrase>
<phrase data="public_site_current_editors_hint">Они продолжают управлять текущим выводом и не копируются автоматически.</phrase>
<phrase data="public_site_ctrl_save">Ctrl/Cmd+S сохраняет черновик и оставляет редактор открытым.</phrase>
<phrase data="public_site_stable_assignment_name">Стабильное имя для назначений.</phrase>
<phrase data="public_site_purpose_example">Например: карточка материала в списке новостей</phrase>
<phrase data="public_site_presentation_editor">Редактор представления</phrase>
<phrase data="public_site_wrapper">Обёртка</phrase>
<phrase data="public_site_empty_result">Пустой результат</phrase>
<phrase data="public_site_assignments">Назначения</phrase>
<phrase data="public_site_item_template">Шаблон элемента</phrase>
<phrase data="public_site_item_available">Один материал доступен как item.</phrase>
<phrase data="public_site_list_wrapper">Обёртка списка</phrase>
<phrase data="public_site_wrapper_hint">Готовые элементы доступны в content, исходные данные — в items.</phrase>
<phrase data="public_site_empty_hint">Показывается, когда подборка не вернула материалов.</phrase>
<phrase data="public_site_presentation_css">CSS представления</phrase>
<phrase data="public_site_css_hint">Стили относятся только к этому представлению.</phrase>
<phrase data="public_site_available_data">Доступные данные</phrase>
<phrase data="public_site_insert_data_hint">Нажмите значение, чтобы вставить его в шаблон элемента.</phrase>
<phrase data="public_site_real_preview">Предпросмотр на реальном материале</phrase>
<phrase data="public_site_preview_safe">Используется только черновик, публичный сайт не меняется.</phrase>
<phrase data="public_site_choose_material">Выберите материал</phrase>
<phrase data="public_site_preview_data_hint">После запуска здесь появятся данные, доступные шаблону.</phrase>
<phrase data="public_site_where_to_use">Где использовать</phrase>
<phrase data="public_site_legacy_mode_hint">Режим «Текущий вывод» только фиксирует связь и ничего не переключает.</phrase>
<phrase data="public_site_content_list">Список материалов</phrase>
<phrase data="public_site_compact_content_list">Компактный список</phrase>
<phrase data="public_site_search_results">Результаты поиска</phrase>
<phrase data="public_site_related_items">Похожие материалы</phrase>
<phrase data="public_site_catalog_filters">Фильтры каталога</phrase>
<phrase data="public_site_document_page">Страница материала</phrase>
<phrase data="public_site_apply_to">Применить к</phrase>
<phrase data="public_site_target_code">ID или код цели</phrase>
<phrase data="public_site_assign">Назначить</phrase>
<phrase data="public_site_change_history">История изменений</phrase>
<phrase data="public_site_no_snapshots">Снимков пока нет.</phrase>
<phrase data="public_site_choose_snapshot">Выберите снимок выше.</phrase>
<phrase data="public_site_place">Место</phrase>
<phrase data="public_site_everywhere">Везде</phrase>
<phrase data="public_site_to_content_type">Типу контента</phrase>
<phrase data="public_site_to_collection">Подборке</phrase>
<phrase data="public_site_to_catalog">Каталогу</phrase>
<phrase data="public_site_to_module">Модулю</phrase>
<phrase data="public_site_diag_page_title">Диагностика публичной страницы</phrase>
<phrase data="public_site_diag_page_intro">Показывает, из чего собрана страница и почему документ попадает в подборку. Ничего не сохраняет и не переключает.</phrase>
<phrase data="public_site_diag_page_context">Контекст страницы</phrase>
<phrase data="public_site_diag_page_composition">Из чего собрана страница</phrase>
<phrase data="public_site_diag_page_hint">Введите публичный URL или ID документа. Проверка читает сохранённые данные без запуска страницы.</phrase>
<phrase data="public_site_diag_inspect">Разобрать страницу</phrase>
<phrase data="public_site_diag_failed">Страница не разобрана</phrase>
<phrase data="public_site_diag_results">Результатов проверки</phrase>
<phrase data="public_site_diag_open_document">Открыть документ</phrase>
<phrase data="public_site_diag_open_setting">Открыть место настройки</phrase>
<phrase data="public_site_diag_render_chain">Цепочка вывода</phrase>
<phrase data="public_site_diag_templates">Шаблоны</phrase>
<phrase data="public_site_diag_characters">символов</phrase>
<phrase data="public_site_diag_document_cache">Кеш документа</phrase>
<phrase data="public_site_diag_full_page">Полная страница</phrase>
<phrase data="public_site_diag_document_template">Шаблон документа</phrase>
<phrase data="public_site_diag_not_created">не создан</phrase>
<phrase data="public_site_diag_dependencies">Зависимости</phrase>
<phrase data="public_site_diag_connected_components">Подключённые компоненты</phrase>
<phrase data="public_site_diag_components_hint">Блоки, подборки, меню и модули, найденные в активной цепочке шаблонов.</phrase>
<phrase data="public_site_diag_where_connected">Где подключено</phrase>
<phrase data="public_site_diag_open_component">Открыть компонент</phrase>
<phrase data="public_site_diag_no_components">Компоненты в шаблонах не найдены</phrase>
<phrase data="public_site_diag_document_schema">Схема документа</phrase>
<phrase data="public_site_diag_fields_values">Поля и фактические значения</phrase>
<phrase data="public_site_diag_fields_hint">Показываются все поля рубрики, включая пустые и обязательные.</phrase>
<phrase data="public_site_diag_field">Поле</phrase>
<phrase data="public_site_diag_type">Тип</phrase>
<phrase data="public_site_diag_value">Значение</phrase>
<phrase data="public_site_diag_required_empty">обязательное не заполнено</phrase>
<phrase data="public_site_diag_empty">пусто</phrase>
<phrase data="public_site_diag_filled">заполнено</phrase>
<phrase data="public_site_diag_no_fields">У рубрики нет полей</phrase>
<phrase data="public_site_diag_rubric_collections">Подборки рубрики</phrase>
<phrase data="public_site_diag_no_alias">без alias</phrase>
<phrase data="public_site_diag_no_requests">Запросов для этой рубрики нет</phrase>
<phrase data="public_site_diag_direct_module_hint">Документ может использоваться напрямую или через модуль.</phrase>
<phrase data="public_site_diag_result_design">Оформление результатов</phrase>
<phrase data="public_site_diag_no_presentations">Назначенных представлений нет</phrase>
<phrase data="public_site_diag_standard_templates">Страница использует обычные шаблоны AVE.cms.</phrase>
<phrase data="public_site_diag_structured_page">Структурированный пакет страницы</phrase>
<phrase data="public_site_diag_why_in_request">Почему документ попал в запрос</phrase>
<phrase data="public_site_diag_parameters_hint">Параметры можно передать как</phrase>
<phrase data="public_site_diag_or_json">или JSON-объект.</phrase>
<phrase data="public_site_diag_request_id">ID запроса</phrase>
<phrase data="public_site_diag_document_id">ID документа</phrase>
<phrase data="public_site_diag_page_parameters">Параметры страницы</phrase>
<phrase data="public_site_diag_explain">Объяснить</phrase>
<phrase data="public_site_diag_request_failed">Запрос не проверен</phrase>
<phrase data="public_site_diag_check_result">Итог проверки</phrase>
<phrase data="public_site_diag_document_included">Документ входит в результат</phrase>
<phrase data="public_site_diag_document_excluded">Документ не входит в результат</phrase>
<phrase data="public_site_diag_found">найден</phrase>
<phrase data="public_site_diag_excluded">исключён</phrase>
<phrase data="public_site_diag_structured_request">Структурированное объяснение запроса</phrase>
<phrase data="public_site_diag_passed">выполнена</phrase>
<phrase data="public_site_diag_failed_state">не выполнена</phrase>
<phrase data="public_site_diag_skipped">не участвовала</phrase>
<phrase data="public_site_diag_no_value">нет значения</phrase>
<phrase data="public_site_diag_empty_template">пустой</phrase>
<phrase data="public_site_diag_filled_template">заполнен</phrase>
<phrase data="public_site_diag_component">Компонент</phrase>
<phrase data="public_site_diag_tag">Тег</phrase>
</language>
@@ -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;

Some files were not shown because too many files have changed in this diff Show More