# Админка

Vue 3 + Inertia поверх тех же сервисов, что обслуживают сайт. Дизайн повторяет
действующую KORZILLA: белое левое меню, тёмная верхняя панель, зелёный акцент,
модальные окна с вертикальной лентой вкладок.

Палитра снята пипеткой со скриншотов рабочей системы, а не подобрана на глаз:
акцент `#71C80A`, панель `#292A2C`, фон `#E8E9ED`, разделители `#E0E0E2`.

## Где живёт

`/admin` на домене самого сайта. Арендатор уже определён резолвером хоста,
поэтому отдельного механизма выбора сайта не нужно.

Маршруты подключает `CoreServiceProvider::bootAdminRoutes()`, а не
`routes/web.php`, и порядок здесь принципиален: catch-all `/{path?}` из web.php
перехватил бы `/admin` и отдал 404 несуществующего раздела.

```
src/Core/Admin/            гвард, меню, контроллеры, middleware
resources/js/admin/        приложение Vue: страницы, компоненты, оболочка
resources/views/platform/admin.blade.php   корневой шаблон Inertia
routes/admin.php
```

Фронтенд собирается отдельным бандлом: посетитель сайта не должен тянуть Vue
и Inertia ради страницы, которую Blade отрисовал на сервере.

## Вход

Учётная запись самого сайта: почта и пароль. Платформенного пароля,
общего на все сайты установки, больше нет — он снят в M6 вместе с появлением
таблицы `admins`.

```bash
php artisan admin:create medtehnika owner@example.com --role=owner
```

Пароль команда спрашивает скрытым вводом: пароль из аргумента остаётся
и в истории оболочки, и в списке процессов. Ключ `--reset` меняет пароль
существующей учётной записи.

### Забыли пароль

Ссылка на смену пароля приходит письмом (`/admin/forgot`). До этого
единственным способом была консоль, то есть звонок разработчику, — а владелец,
заходящий в админку раз в месяц, теряет пароль не в порядке исключения.

- **Ответ формы одинаков всегда** — и на заведённую почту, и на любую другую.
  Иначе форма отвечает на вопрос «а есть ли на сайте такой администратор».
- **Токен лежит в базе хэшем**: утёкший дамп не должен открывать админку —
  ровно по той же причине, по которой рядом лежит не пароль, а его bcrypt.
- **Ссылка живёт час и срабатывает один раз.** Новый запрос гасит прежние
  ссылки: забытая во входящих ссылка — это вход, о котором никто не помнит.
- **Частота ограничена по двум ключам**, как и у входа: по IP и по почте.
  Второй нужен не от перебора, а чтобы форма не стала способом завалить
  письмами конкретного владельца.
- Смена пароля меняет отпечаток в сессии, поэтому **остальные устройства
  разлогиниваются сами** — и это как раз то, что нужно, когда паролем
  воспользовались из-за потери доступа.
- Отключённая учётная запись ссылку не получает.

Письмо уходит через `SiteMailer`, то есть с адреса сайта (см. [shop.md](shop.md#письма-о-заказе)).

- **Сайт без учётных записей открыть нельзя.** Экран входа говорит об этом
  прямо, а не отвечает «неверный пароль» на всё подряд.
- **Ошибка входа одна на все случаи** — «Неверная почта или пароль». Разный
  текст на неизвестную почту превратил бы форму входа в справочник учётных
  записей сайта. По той же причине при отсутствии учётки всё равно считается
  bcrypt: иначе время ответа выдаёт, какие адреса заведены.
- **Два лимита попыток**: пять в минуту на пару «сайт + IP» и десять на пару
  «сайт + почта». Первый ловит перебор с одной машины, второй — перебор,
  размазанный по ботнету: сменить IP дёшево, а цель атаки — конкретный владелец.
- **Сессия помечена тремя вещами** — сайтом, идентификатором учётки
  и отпечатком пароля. Отсюда следует: вход в один сайт не открывает админку
  другого; отключение учётки закрывает её открытые сессии немедленно, а не
  «со следующего входа»; смена пароля разлогинивает все прочие устройства.

### У каждого сайта свой администратор

Таблица `admins` — `tenant`: у сайта свой владелец, и выгрузка сайта обязана
уехать вместе с его учётными записями.

- **Уникальность почты — по `(site_id, email)`**, а не глобально. Агентство
  ведёт десяток сайтов с одной почтой; глобальный ключ и запретил бы это,
  и сломал бы восстановление персайтового дампа на общей установке. То же
  требование записано для `user_phones` и `auth_identities`.
- Почта хранится нормализованной (нижний регистр, без пробелов): сравнение
  при входе не должно зависеть от collation колонки.
- **Супер-админ платформы (M9) — отдельная сущность** вне этой таблицы:
  учётки площадки не должны уезжать в выгрузку сайта.
- Ролей две. `owner` ведёт список учётных записей, `admin` — всё остальное.
  Разграничение прав по разделам и модулям без живых сайтов проектируется
  вслепую, поэтому его нет намеренно.

### Правила, из-за которых сайт может остаться без доступа

Все они проверяются в `AdminWriter`, а не в контроллере: те же правила нужны
консольной команде и будущей фабрике сайтов, а продублированные — разъедутся.

- последнего действующего владельца нельзя ни удалить, ни разжаловать,
  ни отключить;
- себя нельзя удалить, разжаловать и отключить: это единственное действие
  в админке, которое немедленно отбирает доступ у того, кто его выполняет;
- пустое поле пароля означает «не менять» — как поле секрета в настройках;
- пароль короче 10 символов не принимается;
- роль проверяется на сервере в каждом действии. Обычный администратор видит
  на экране только себя, но запрос отправить может кто угодно вошедший, и
  «не показали кнопку» не значит «не может выполнить».

### Кука админки

У админки собственная кука сессии с путём `/admin` (`IsolateAdminSession`,
глобальный middleware до старта сессии). Сессия посетителя и сессия
администратора — две разные записи, и браузер вообще не отправляет админскую
куку на страницы сайта.

> ⚠️ Это уменьшает поверхность, но не закрывает её: XSS на домене сайта
> остаётся смертельным — скрипт дёрнет `/admin/...` тем же источником, и кука
> уедет вместе с запросом. Полная развязка — отдельный хост админки, M9,
> вместе с платформенным супер-админом.

## Левое меню

Меню — реестр `AdminMenu`, а не список в шаблоне.

```php
$this->callAfterResolving(AdminMenu::class, function (AdminMenu $menu): void {
    $menu->group('shop', 'Магазин', icon: 'store', order: 30)
        ->link('orders', 'Заказы', '/admin/shop/orders');
});
```

Группа создаётся идемпотентно: два модуля, дописывающих пункты в «Магазин»,
получают одну группу, а не две одинаковые.

Меню **собирается заново на каждое обращение** (`bind`, а не `scoped`)
и наполняется лениво — в момент, когда экран отдаёт общие данные. Схемы
настроек регистрируются в `boot()` модулей, то есть позже ядра; собирать меню
раньше было бы нечем.

⚠️ Раньше реестр был `scoped`. Меню — это вид данных текущего сайта, в нём
пункты и экраны настроек персайтового кода, а scoped-объект переживает запрос
везде, где его не сбрасывают явно. Тест изоляции поймал, что меню одного
клиента доставалось соседу.

**Во фронтенд** меню едет Inertia-свойством `menu` из `HandleInertiaRequests`:
дерево `[{key, label, icon, url?, children?}]`. Пункты отключённых сайту модулей
отфильтрованы, пустая группа исчезает. Рисует `Layouts/AdminLayout.vue`, значки —
`Components/AdminIcon.vue`: для меню `site`, `settings`, `layout`, `store`, `users`,
`file`; там же значки типов страниц и кнопок строк дерева разделов.

## Вид экрана

Все экраны собраны одинаково, и новый обязан повторять это устройство:

| Часть | Где | Как |
|---|---|---|
| заголовок и описание | на сером фоне | `h1` `text-[28px] font-light`, под ним `text-[13px] text-kz-muted` |
| главная кнопка | справа от заголовка | зелёная заливка, «+ Новый …» |
| фильтры и поиск | белая плашка над списком | `mb-5 bg-kz-surface px-6 py-4` |
| список, форма, таблица | белая подложка | `bg-kz-surface px-8 py-6`, шапка списка — `text-[12px] uppercase text-kz-muted` |
| группы внутри экрана | отдельные белые подложки | заголовок группы — `text-[15px] font-medium uppercase text-kz-accent` |

**Содержимое на сером фоне не лежит.** Поле с подчёркиванием, таблица и
«пилюли» на сером читаются плохо — владелец заметил это на «Формах и заявках»,
«Баннерах» и «Шаблонах карточки», и все три приведены к подложкам вместе
с поиском «Заказов» и «Покупателей».

⚠️ **Цвета — только из темы админки** (`@theme` в `resources/css/admin.css`).
Tailwind молча не создаёт класс с несуществующим цветом: `bg-kz-soft` ни разу
не сработал, и в глобальном поиске не было видно подсказки, выбранной
стрелками, а у строк «Баннеров» — подсветки при наведении.

**Персайтовое расширение** — интерфейсы `ExtendsAdminMenu`, `DeclaresSettings`,
`DeclaresAdminRoutes` на `overrides/{ключ}/Booter.php`, с примером
в [overrides/README.md](../overrides/README.md). Платформа зовёт их только для
текущего сайта и после модулей, поэтому пункт клиента встаёт в «Магазин»,
а не заводит второй такой же.

## Экран настроек

Их не пишут — их порождает схема. Один маршрут `settings/{schema}` и один
Vue-компонент обслуживают настройки всех модулей платформы.

> Легаси держал на каждый экран собственный шаблон и собственный обработчик
> сохранения, из-за чего 37 манифестов обслуживались десятками почти одинаковых
> файлов.

Как это работает:

1. `SettingsSchemaRegistry` отдаёт схему; она сериализуется в JSON вместе
   с деревьями условий видимости.
2. Vue рисует поля по типу (`SettingType`) и сам считает видимость.
3. Сервер при сохранении считает видимость **заново** и работает только
   с видимыми полями.

### Условия видимости считаются дважды

`resources/js/admin/composables/condition.js` — зеркало `Condition::evaluate()`.
Обе реализации проверяются одной фикстурой `tests/fixtures/condition-cases.json`:
два похожих списка случаев разошлись бы незаметно.
Правила приведения типов повторяют PHP намеренно: `'0'` здесь ложно, как в PHP,
а не истинно, как в обычном JavaScript. Разойдись они — клиент показал бы поле
там, где сервер считает его скрытым, значение молча не сохранилось бы, и понять
это по форме было бы невозможно.

Клиенту при этом не доверяют: что сохранять и что валидировать, решает сервер.

### Правила, которые легко нарушить

- **Скрытое поле не валидируется и не сохраняется.** Обязательное поле внутри
  скрытой группы не должно блокировать сохранение формы.
- **Ключи, которых нет в схеме, отбрасываются.** Иначе форма настроек новостей
  могла бы писать в настройки каталога подменой ключа в запросе.
- **Секрет не уезжает в браузер.** Вместо значения — факт «задан»; пустое
  поле означает «не менять». Иначе сохранение соседней настройки стирало бы
  ключи всех интеграций сайта.
- **Условие вправе смотреть на настройку соседнего модуля**, поэтому вместе
  со схемой едет `conditionContext` — значения ключей из условий, которых
  в самой схеме нет.
- Ключ настройки содержит точку (`news.per_page`), а валидатор Laravel читает
  точку как путь во вложенный массив. В правилах она экранируется.
  ⚠️ Подпись поля при этом кладётся под обоими ключами — экранированным
  и обычным: под одним экранированным валидатор её не находил, и сообщение
  выходило «Поле "design.css"…» вместо подписи.

### Редактор кода

Поле типа `code` (свой CSS сайта) рисует CodeMirror 6, а не Monaco из легаси:
Monaco весит несколько мегабайт, а здесь нужны подсветка, автодополнение
свойств и поиск — это сотни килобайт. Грузится он отдельным куском, только
когда поле появилось на экране: основной бандл админки из-за него не вырос.

- **Не загрузился кусок — поле становится обычным текстом.** Писать CSS можно
  и без подсветки, а потерять форму нельзя.
- **Внешняя смена значения сравнивается с текстом редактора**, прежде чем
  заменить его: иначе каждое нажатие клавиши возвращалось бы в редактор
  заменой всего текста, и курсор прыгал бы в начало.
- **Скобки CSS проверяются на лету** (`composables/cssBraces.js`, тесты —
  `tests/js/cssBraces.test.js`), без учёта строк и комментариев. Это
  предупреждение, а не отказ: лишнюю скобку браузер переживёт, пропустив
  правило.
- **Tab — отступ**, как в любом редакторе кода. Выйти из поля клавиатурой —
  Esc, затем Tab: так устроен CodeMirror, и это сказано в описании поля для
  экранного диктора.

#### На весь экран

Поле кода лежит в рамке `FullscreenFrame.vue` с кнопкой «Развернуть».
У группы вкладками рамка одна на всю ленту: развернув «Все экраны»,
верстальщик переходит к «Телефону», не сворачивая редактор.

- **Разворачивается на месте** — `position: fixed` поверх админки, без переноса
  в `<body>`. Перенос сбросил бы фокус и выделение в редакторе и вынес бы поле
  за пределы `<form>`. ⚠️ Цена: у предков рамки не должно быть `transform`,
  `filter` и `contain` — они становятся точкой отсчёта для `fixed`, и рамка
  развернулась бы внутри них.
- **Размер редактора — тема в отсеке** (`Compartment`): разворот меняет только
  её, не пересоздавая редактор, — история правок и курсор остаются.
- ⚠️ **Место рамки держит заглушка её высоты.** Развёрнутая рамка выпадает
  из потока, страница становится короче экрана, браузер сбрасывает прокрутку
  в ноль, и после «Свернуть» человек оказывался наверху. Нашлось прогоном
  собранного бандла в браузере: jsdom раскладку не считает, и тест этого
  не увидел бы.
- **Прокрутка страницы заперта**, пока рамка развёрнута: колесо, докрутившее
  редактор до конца, иначе листало бы невидимую форму. Резерва под полосу
  прокрутки нет намеренно — он оставлял серую полосу у края редактора.
- **Esc сворачивает, только если его не потратил редактор.** Закрывая
  подсказку автодополнения или поиск, CodeMirror отменяет событие, и рамка
  такой Esc пропускает. По той же отметке `Modal.vue` не закрывается от Esc,
  который уже кто-то обработал.
- **Ctrl+S сохраняет форму** и в развёрнутом виде, где кнопка «Сохранить
  изменения» закрыта рамкой. Какую форму — рамка не знает: страница
  предлагает сохранение через `provide(SAVE_FORM)`, и рядом с кнопками рамки
  видно «Сохраняется…», «Сохранено» или «Не сохранено — есть ошибки» —
  сообщение страницы лежит под рамкой. Не предложила — Ctrl+S остаётся
  за браузером.
- ⚠️ **Клавиша сохранения узнаётся по `code`, а буква — запасной путь.**
  По букве в русской раскладке пришло бы «ы»; а `code` пуст у удалённого
  рабочего стола и экранной клавиатуры — автоматизация браузера при проверке
  прислала именно такое событие.
- **Tab ходит по кругу внутри развёрнутой рамки**: иначе фокус уходил бы
  на поля формы, лежащие под ней.

#### Вкладки группы

`SettingsTabs.vue` рисует группу, объявленную `asTabs()`
([settings.md](settings.md#группа-вкладками)).

- **Панели прячутся `v-show`, а не `v-if`**: редактор на закрытой вкладке
  хранит историю правок и курсор. Скрытый при создании редактор меряет себя
  сам, когда панель показали, — проверено в браузере.
- **Лента по образцу WAI-ARIA**: стрелки по кругу, Home и End, у неоткрытых
  вкладок `tabindex="-1"`.
- Решения вкладок — чистые функции в `composables/tabs.js`, поведение рамки
  и ленты — `tests/js/fullscreenFrame.test.js` (jsdom объявляется в самом
  файле, прогон всего набора — секунды).

## Разделы

Дерево с перетаскиванием между уровнями: перетаскивание и есть смена родителя,
отдельной кнопки «перенести» в KORZILLA нет. Один запрос несёт и нового
родителя, и порядок среди соседей — для пользователя это одно действие.

Главную перетаскивать нельзя: её путь `/` — корень сайта, а не позиция в списке.

### Строка дерева

Устроена как «Сайт → Разделы» легаси: значок типа, название, номер раздела
и тип страницы по-русски. При наведении номер и тип уступают место кнопкам:
переключатель «включён», карандаш «изменить», «+ Подраздел», корзина.
Рядом с названием — «открыть на сайте».

- **Название ведёт внутрь раздела** — к его содержимому. Настройки открывает
  карандаш: раньше их открывал щелчок по названию, и об этом приходилось
  догадываться. У раздела без типа страницы содержимого нет, и название
  открывает настройки.
- **Адреса в списке нет.** Он виден в окне раздела, а в дереве занимал место
  и мешал читать названия. Номер — как в легаси: по нему раздел ищут
  в переписке и в журнале.
- **Значок типа объявляет компонент** (`HasAdminIcon`), а не админка: ядро
  не знает, какие модули стоят на установке. Не объявил — значок обычной
  страницы. Свои значки у главной, внешней ссылки и раздела без типа.
- **«+ Подраздел» открывает окно с родителем и его типом страницы**:
  подраздел каталога почти всегда тоже каталог. Раньше раздел создавался
  только в корне и потом перетаскивался.
- **Новый раздел встаёт в конец соседей** (`SectionWriter::nextSortOrder`),
  если приоритет не задан. Форма раньше слала «100», а соседи после
  перетаскивания стоят шагом 10 — одиннадцатый раздел оказывался между
  девятым и десятым.
- **Переключатель — отдельный запрос** (`PUT sections/{id}/published`):
  форма раздела шлёт все поля, а в строке дерева их нет, и щелчок затёр бы их.
- **Главную из списка не выключить, не удалить и подраздела в ней не завести**:
  выключенная одним щелчком главная — сайт без первой страницы. Настройки
  главной — карандашом.
- **Кнопки по наведению мыши, по фокусу с клавиатуры и всегда — на сенсорном
  экране.** Фокус считается `:focus-visible`, а не `focus-within`: после щелчка
  по переключателю фокус остаётся в строке, и она показывала бы кнопки, когда
  мышь уже на соседней.
- Решения строки — чистые функции `composables/sectionTree.js`, тесты —
  `tests/js/sectionTree.test.js`.

Картинки разделов для дерева грузятся одним запросом, а не запросом на раздел:
в каталоге с плитками категорий их сотни.

Всё, что меняет путь, идёт через `SectionWriter` — он пересчитывает ветку одним
запросом и оставляет 301 со старых адресов. Список полей, которые правит форма,
задан в писателе явно: `site_id`, `path` и `depth` считает он сам, и запрос
из админки не должен их подменять.

Разделы для экрана берутся запросом, а не из кэша дерева: кэш заточен под резолв
URL, и раздувать горячий ключ полями формы ради экрана, который открывают
несколько раз в день, — плохой размен.

## Зоны и блоки

Карта повторяет «Сайт → Зоны и Блоки»: полосы страницы сверху вниз, внутри
полосы — зоны, внутри зоны — блоки.

- Блоки в карте лежат в **том же CSS Grid**, что и на сайте. В легаси разбиение
  по строкам считалось в PHP, и админская карта показывала не то, что видел
  посетитель.
- Ширина тянется мышью; ширина колонки берётся из фактической ширины сетки —
  у зон разное число колонок и разные отступы.
- `span` обрезается по сетке зоны **на сервере**: блок на 24 колонки
  в 12-колоночной зоне сломал бы строку, а клиенту в этом доверять нельзя.
- Зоны перетаскиваются внутри полосы. Смена полосы — выбор в форме зоны: полоса
  задаёт положение относительно зоны КОНТЕНТ, а не позицию в списке.
- Ключ зоны после создания не редактируется: по нему на зону ссылается тема.
  Раскладка — тем более: перенос зоны из страницы в шаблон карточки утащил бы
  её блоки. ⚠️ Форма присылает `layout_id` при каждом сохранении, и сервер его
  снимает — пустое значение затирало колонку, и любая правка зоны отвечала 500.
- Цвета текста, ссылок, кнопок и значков — в форме зоны и на вкладке «Дизайн»
  у блока. Пусто — наследуется от зоны и от сайта: значения едут переменными
  CSS ([layout.md](layout.md#цвета-зоны-и-блока)).
- Содержимое контейнера (вкладки, колонка) показано внутри его карточки и
  перетаскивается: порядок в списке — это порядок вкладок или строк колонки.
- Правила «выводить в разделах» заменяются целиком через `LayoutWriter`;
  правило, указывающее на раздел другого сайта, отбрасывается.

Подробности модели видимости — [layout.md](layout.md).

## Шаблоны карточки

`Сайт → Шаблоны карточки`: сверху общие шаблоны, ниже — только те типы
страниц, у которых есть свои. Сборка идёт **тем же экраном**, что и «Зоны
и блоки», — только раскладка другая, и полос у неё нет: карточка выводится
внутри центральной колонки, и стоять рядом с ней нечему.

⚠️ Экран не перечисляет все типы страниц подряд. Строка «шаблонов нет»
на каждом из десятка компонентов подсказывала, что шаблон нужен каждому, —
а нужен он там, где карточка правда другая. Остальным хватает общего.

- **Один контроллер на оба экрана**, а не второй набор эндпоинтов: разошедшаяся
  вторая копия правил (обрезка `span`, права арендатора, сброс кэша) стоила бы
  ровно того, ради чего конструктор и делается.
- **Первый шаблон в своей области сразу становится умолчанием**: заведённый
  и никому не назначенный не выводится нигде, и владелец решил бы, что
  конструктор не работает.
- **Удаляется любой шаблон, включая последний.** Карточка без шаблонов
  возвращается к вёрстке компонента, то есть к тому, как она выводилась
  до конструктора. Запрет «последний не трогать» держал бы владельца
  в ловушке из-за случайно созданного шаблона, ничего при этом не защищая.
  Удаление умолчания передаёт флаг соседу.
- **Раздел выбирает шаблон в своей форме**, пустое значение — «как
  у родительского раздела».
- В форме блока появляется «Внутри блока»: так блок становится вкладкой.
  Контейнер во вкладку не кладётся — и в форме, и на сервере.

**Настройки, которых в карточке нет.** Форма зоны и блока одна на оба экрана,
но часть настроек в карточке не значит ничего, и показывать их — приглашение
сломать себе вывод:

| Что скрыто | Почему |
|---|---|
| «Показывать на всех страницах» | где показать карточку, решает шаблон, назначенный разделу |
| «Скрыть на страницах объектов» | карточка и есть страница объекта |
| Страницы пагинации | пагинация бывает у списка, у карточки страница одна |
| Служебные страницы в правилах | карточки корзины и кабинета не бывает |
| «Это шапка», «это подвал», «закрепить при прокрутке» | свойства полосы страницы |
| «Стакан контента» и «ширина фона» | ширину полосе карточки задаёт колонка, в которой стоит карточка |

⚠️ Приводятся к рабочим значениям **на сервере**, а не только скрытием полей:
«показывать на всех страницах» для блока карточки не выбор, а условие работы,
и выключенный старой вкладкой браузера блок молча пропал бы со всех карточек.

Правила по разделам в карточке остаются и означают «в карточках каких
разделов выводить». Это единственный способ сделать блок только для ветки,
не заводя ради него отдельный шаблон.

> ⚠️ Правила «умолчание ровно одно» и «умолчание переходит соседу» живут
> в писателе (`CardLayoutWriter`), а не в контроллере: те же нужны фабрике
> сайтов.

## Содержимое раздела

Экран один на все компоненты: и список, и форма строятся из объявленных полей
(`FieldDefinition`), как экран настроек строится из схемы. Легаси держал
на каждый тип страницы собственную форму, и добавление типа означало
копирование файла.

Открывается из дерева разделов: у объекта нет своего места в меню, он всегда
принадлежит разделу.

- вкладки формы — это группы полей из объявления компонента;
- колонки списка — поля с `inListing()`;
- файлы всех объектов страницы поднимаются одним запросом, а не по объекту;
- на сервер уезжают идентификаторы файлов, а не сами файлы: привязка объекта —
  строка в `mediables`, а не копия.

Подробности контракта и конструктора типов — [components.md](components.md).

## Типы страниц

`Настройки → Типы страниц` — конструктор компонентов. Тип, собранный здесь,
попадает в тот же реестр, что и компоненты модулей, и получает тот же экран
содержимого. Встроенные типы показаны рядом, но не редактируются: их поля
объявлены в коде модуля.

Тип, на котором стоят разделы, не удаляется — это оставило бы их без формы
и без содержимого.

## Редактор текста

Поля типа «текст с оформлением» (в объектах, настройках и html-блоке) рисуются
визуальным редактором с вставкой картинок из медиатеки.

Картинка вставляется **относительным** путём `img/…`; префикс `/a/{login}`
приклеивается на выводе директивой `@kzhtml`. Поэтому в шаблонах для текстов
из редактора используется она, а не `{!! !!}`: забыть про префикс не должно
быть возможности. Абсолютные адреса не трогаются — подстановка сломала бы
внешние картинки и ссылки.

## Поле цвета

`ColorField` — пипетка и код рядом, один компонент на все места, где вводится
цвет: оформление зоны, настройка типа «цвет», поле объекта того же типа.

Двумя способами намеренно. Пипетка нужна, когда цвет подбирают глазом; поле
кода — когда цвет уже назван макетом или брендбуком. Голая `input type="color"`
не позволяет вставить `#1F5FA8` из макета вовсе, а одно текстовое поле требует
знать код наизусть.

Принимается всё, чем цвет обычно копируют: `1f5fa8`, `#abc`,
`rgb(31, 95, 168)`, `rgba(…)`. Приводится к `#RRGGBB` или `#RRGGBBAA` —
колонка в базе `char(9)`.

**Пустое значение — это «не задано», а не белый**, поэтому очистка есть
отдельной кнопкой: у нативной пипетки состояния «пусто» не существует,
и убрать однажды выбранный цвет было бы нечем.

⚠️ **Приводится набранное значение, а не свойство компонента.** Родитель
узнаёт о правке из события, но в свойство она приезжает только со следующей
отрисовкой — на `blur` там всё ещё старое. Из-за этого первая версия поля
не приводила `rgb(31, 95, 168)` к коду, а **стирала** введённое. Нашлось
живым прогоном в браузере, а не сборкой.

Формат проверяется и на сервере: значение уходит в атрибут `style` на каждой
странице сайта, и «строка до девяти символов» там не место.

## Экраны модулей

Админка не заканчивается на экранах ядра: модуль объявляет свои маршруты сам,
через `routeFiles` своего провайдера — с тем же префиксом `admin`, тем же
гвардом и теми же общими данными Inertia.

```php
protected array $routeFiles = [
    'admin.php' => [
        'middleware' => ['web', HandleInertiaRequests::class, RequireAdmin::class],
        'prefix' => 'admin',
        'name' => 'admin.',
    ],
];
```

Так живут справочники и карточка товара в каталоге — [catalog.md](catalog.md#админка).
Ядро о них не знает; ссылку на экран объекта оно показывает по ответу
`Component::adminObjectUrl()`.

Пункты меню модуль добавляет через `callAfterResolving(AdminMenu::class, …)`:
реестр собирается в момент запроса, когда все модули уже загружены.

## Медиатека

См. также [database.md](database.md#медиа).

```
media       uuid, disk, path, name, mime, size, width, height, folder, alt, title
mediables   media_id, mediable_type, mediable_id, collection, sort_order
```

- **Путь в базе относительный**: `img/3f/2a/{uuid}.webp`, без `/a/{login}/`.
  Префикс приклеивается на выводе. Это инвариант 2 из
  [architecture.md](architecture.md#2-пути-к-медиа--относительные).
- Имя файла на диске — uuid. Кириллица, пробелы и двойные расширения
  в оригинальном имени в путь не попадают; имя хранится отдельным полем.
- Шардирование двумя уровнями hex — 65 536 каталогов. Плоский каталог
  на миллион файлов не открывается ни по FTP, ни через `ls`.
- Исполняемые расширения (`php`, `phtml`, `phar`, `htaccess`, …) отклоняются:
  каталог сайта доступен по адресу `/a/{login}`, и PHP-файл там — это
  удалённое исполнение кода, а не просто неудачная загрузка.
- Привязки вынесены в `mediables`: один файл может стоять и обложкой раздела,
  и картинкой блока, и не должен из-за этого копироваться на диске.

### Панели модулей в окне объекта

Форма объекта строится из полей компонента, но у товара есть то, что
полями не выражается: цены, остатки, характеристики, варианты. Раньше это
жило отдельной страницей, и найти её было нельзя — владелец открывал товар,
не видел цен и решал, что задать их негде.

Теперь модуль объявляет **разделы** (`HasAdminObjectScreen::adminObjectPanels`),
и каждый встаёт отдельным пунктом того же левого меню, что и вкладки полей.
Одной вкладкой они были вложены вторым уровнем внутрь первого, и владелец
каждый раз вспоминал, на каком он уровне. Имя компонента
называет модуль, ядро только резолвит его среди `Components/Panels/**`:
иначе экран содержимого пришлось бы править ради каждого нового типа
страницы.

- **Компонент один на все разделы.** Данные у них общие: один запрос
  на загрузку и один на сохранение. Активный раздел приезжает свойством,
  а четыре экземпляра панели дали бы четыре загрузки одного и того же.
- **Он же работает на отдельной странице карточки**: второй интерфейс
  для тех же цен разошёлся бы с первым на второй неделе.
- **Способ покупки товара — обычное поле** компонента (группа «Разное»),
  а не настройка на панели цен: это свойство товара, а не его прайса,
  и в двух местах оно однажды разошлось бы.
- **Данные грузятся по требованию** (`GET /admin/catalog/products/{id}/data`),
  когда владелец открыл вкладку. Вместе со списком объектов их тянуть
  незачем: это характеристики полусотни товаров разом.
- **Панель показывается только у сохранённого объекта**: цены нельзя
  задать товару, которого ещё нет.
- **Сохранение своё** — один запрос на всю панель. Цены, остатки
  и характеристики правят пачкой, и промежуточные состояния «цена
  сохранилась, остаток нет» никому не нужны.

### Всё растровое хранится в WebP

Загруженная картинка конвертируется при сохранении: JPEG, PNG, BMP и сам WebP
приводятся к `image/webp` с качеством 82. Формат один на всю медиатеку, потому
что WebP меньше JPEG при том же качестве и, в отличие от него, умеет
альфа-канал — логотип с прозрачным фоном и фотография товара хранятся
одинаково, и вёрстке не нужно знать, чем файл был до загрузки.

⚠️ **Порядок вызовов при конвертации имеет значение.** Палитровый PNG сначала
переводится в truecolor (`imagepalettetotruecolor`), затем выключается
смешивание и включается сохранение альфы. В обратном порядке прозрачный фон
становится чёрным.

Не конвертируются и остаются как есть:

| Что | Почему |
|---|---|
| SVG | вектор; растеризовать — испортить |
| GIF | GD не перенесёт анимацию в WebP, а тихо остановленная картинка хуже нетронутой |
| PDF, документы, архивы | не картинки |
| картинка, не помещающаяся в память | фотография 8000×6000 занимает ~190 МБ при `memory_limit = 128M`: попытка открыть её уронила бы запрос, и владелец увидел бы «файл не загрузился» вместо файла |

Имя файла в базе остаётся исходным (`Логотип компании.png`): владелец ищет файл
по тому, как назвал его сам.

### Отдача файлов

`/a/{login}/{путь}` обслуживает маршрут ядра (`MediaFileController`).

⚠️ Раньше здесь было написано «каталог отдаётся веб-сервером», и отдавать его
было некому: ни встроенный сервер разработки, ни стенд, ни хостинг клиента
алиаса не настраивали. Файл ложился на диск и отвечал 404 по своему адресу —
для владельца это выглядело как «файлы не грузятся».

- **Логин в адресе обязан совпасть с сайтом**, который резолвился по хосту:
  иначе через домен одного арендатора читались бы файлы другого.
- **Белый список каталогов** — `img`, `doc`. Рядом лежит `imp` с пакетами
  обмена 1С: цены, остатки, вся номенклатура. Запрещающий список однажды
  не узнал бы о новой папке, разрешающий в худшем случае не отдаст лишнего.
- **`..` отсекается сборкой пути из сегментов**, а не поиском подстроки:
  подстроку легко обойти кодированием, отсутствующий сегмент — нет.
- **Кэш навсегда** (`immutable`): имя файла — uuid, содержимое по нему
  не меняется, перезалитая картинка получает новый адрес.

Отдача через PHP работает везде и сразу, но медленнее алиаса. Как отдать
каталог мимо приложения — в [operations.md](operations.md).

Окно выбора файла берёт список JSON'ом (`GET /admin/media/list`), а не переходом
Inertia: оно открывается поверх заполненной формы, и полная перезагрузка стёрла
бы несохранённые значения.

## Что ещё не сделано

| Что | Когда |
|---|---|
| Права по разделам и модулям (сейчас только роли owner/admin) | по мере надобности |
| Админка на отдельном хосте — полная развязка от XSS на сайте | M9 |
| Восстановление пароля почтой (сейчас только `admin:create --reset`) | M6, вместе с отправкой писем |
| Формы зоны и блока покрывают не все колонки таблиц | по мере надобности |
| Экраны городов и групп пользователей | M6 |
| Связи товара и цены вариантов в интерфейсе | хвост M5 |

## Панель редактора на живом сайте

Администратор смотрит сайт как посетитель и видит то, что нужно поправить.
Без панели путь до правки такой: вспомнить адрес админки, найти раздел
в дереве, найти блок в карте зон. Панель сокращает его до одного щелчка
по тому, что на экране: ссылка на правку открытого раздела, переход в зоны
и блоки, переключатель подсветки блоков (у каждого уже есть `data-kz-block`).

Вставляется middleware в готовый ответ, а не темой — по той же причине, что
и мета-теги: панель обязана работать на любой вёрстке, включая заказанную
клиентом на стороне. И не попадает в кэш блоков: она добавляется к собранной
странице, иначе посетитель получил бы кнопки правки из кэша, оставленного
администратором.

⚠️ **У панели своя метка, а не сессия админки.** Кука сессии живёт с путём
`/admin` (решение M6: сессия посетителя и сессия администратора — не одна
запись), и на страницах сайта браузер её не присылает вовсе. Поэтому вход
ставит отдельную куку `kz_editor` с путём `/`.

**Метка не даёт прав.** В ней сайт и учётка, по ней рисуется панель — и всё;
любое действие уходит на `/admin`, где спрашивают настоящую сессию. Именно
поэтому её можно отдавать на весь сайт, а сессию — нельзя.

⚠️ **Метку расшифровывает сам middleware.** `EncryptCookies` стоит в списке
приоритетов Laravel, а панель — нет, и фактический порядок оказывается не тем,
которым его объявили: значение приходило зашифрованным, и панель молча
не показывалась. **Нашлось тестом, подтверждено живым прогоном.**

Панель не появляется у посетителя, в самой админке, в ответах API и на 404:
править там нечего.
