Руководство разработчика API¶
Это руководство описывает, как разработчики плагинов могут интегрироваться с API Grav и Admin2 — новым SPA-плагином админки на Svelte 5 (работающим на API), который заменяет устаревший классический плагин Admin в Grav 2.0.
Расширение API¶
Любой плагин может добавлять пользовательские эндпоинты API. Полное пошаговое руководство см. в разделе Интеграция плагинов с API.
Краткая версия:
- Подпишитесь на
onApiRegisterRoutesв своём плагине - Зарегистрируйте маршруты, указывающие на класс контроллера
- Создайте контроллер, расширяющий
AbstractApiController
Пользовательские поля админки через веб-компоненты¶
Admin2 рендерит формы конфигурации плагинов по схемам, как и классическая админка. Стандартные типы полей (text, toggle, select, array, list и т. д.) работают автоматически.
Для пользовательских типов полей — полей со специализированным интерфейсом, который стандартные типы не покрывают, — плагины могут поставлять веб-компоненты, которые Admin2 загружает по требованию.
Темы тоже могут предоставлять пользовательские поля. Всё в этом разделе в равной степени относится к теме: разместите файл в
your-theme/admin-next/fields/yourfieldtype.js. Admin2 отслеживает, какой поставщик предоставляет каждый тип поля, и загружает его скрипт с соответствующего маршрута (/gpm/themes/{slug}/field/{type}для темы,/gpm/plugins/{slug}/field/{type}для плагина), поэтому поле, предоставленное темой, загружается корректно при использовании в любой схеме.
Как это работает¶
- Admin2 встречает в схеме неизвестный тип поля
- Он проверяет, объявил ли плагин пользовательские поля в своём ответе API
- Если да, он загружает набор полей плагина из API — все скрипты полей этого плагина одним запросом (см. Загрузка, объединение и кэширование)
- JavaScript определяет пользовательский элемент
- Admin2 монтирует элемент и общается через свойства и события
Соглашение о файлах¶
Размещайте JavaScript-файлы веб-компонентов по пути:
your-plugin/
admin-next/
fields/
yourfieldtype.js # Один JS-файл на каждый пользовательский тип поля
Когда Admin2 загружает страницу сведений о плагине, API автоматически находит файлы в admin-next/fields/ и включает их в ответ:
Загрузка, объединение и кэширование¶
Вам не нужно ничего делать, чтобы включить это, но стоит понимать, как Admin2 на самом деле загружает ваши скрипты полей, — потому что из этого следует одно правило, которое нужно соблюдать.
Один запрос на плагин, а не на поле. Когда схема выводит на одном экране несколько ваших пользовательских полей, Admin2 не загружает их по одному. Он запрашивает весь набор полей плагина одним вызовом:
API читает каждый файл в вашей папке admin-next/fields/ и возвращает их как одну JSON-карту { "fieldType": "<javascript source>", … }. Admin2 загружает этот набор один раз на плагин, а затем выполняет исходный код каждого поля локально, поэтому плагин с семью пользовательскими полями стоит одного сетевого обращения, а не семи. (Исходный маршрут для отдельного поля, GET …/field/{type}, по-прежнему существует и используется классической админкой и как запасной вариант.)
Важно
Поскольку исходный код каждого поля выполняется отдельно — каждый в своей области с собственным window.__GRAV_FIELD_TAG, — каждый файл в admin-next/fields/ должен быть самодостаточным. Не делите переменные верхнего уровня между двумя файлами полей и не делайте import одного файла поля из другого; считайте каждый отдельным скриптом, заканчивающимся customElements.define(TAG, …). Вынесите общую логику в шаг сборки, который встраивает её в каждый выходной файл, или продублируйте нужный небольшой фрагмент.
Кэшируется агрессивно, проверяется дешёво. Набор (и маршрут для отдельного поля) несёт ETag, построенный по времени изменения и размеру ваших файлов, а Admin2 также кэширует тело ответа в localStorage. При каждой следующей загрузке он перепроверяет через If-None-Match, поэтому неизменённый набор возвращается как крошечный 304 Not Modified и отдаётся из кэша — ваше многомегабайтное поле редактора скачивается один раз на выпуск, а не при каждом открытии страницы. Поскольку валидатор отслеживает mtime и размер файлов, пересборка поля во время разработки немедленно делает его недействительным, поэтому при итерациях вы всегда получаете свежий код.
Ограничивается так же, как всё остальное, но дёшево. Эти маршруты скриптов учитываются в ограничении частоты запросов API на пользователя (plugins.api.rate_limit, по умолчанию 120 запросов за 60 секунд), как и любой другой запрос. Единственный путь, освобождённый по умолчанию, — /sync/, опрос совместной работы в реальном времени, задаваемый в plugins.api.rate_limit.excluded_paths. На практике скрипты редко накапливаются: Admin2 загружает один набор на плагин, неизменённый набор возвращается как 304, Admin2 ограничивает число параллельных запросов, а если он всё же получает 429, он отступает и повторяет попытку вместо того, чтобы сломать поле.
Примечание
Этот приём — обнаружить вклад плагина на диске, отдать его одним условно кэшируемым набором и распределить работу на клиенте — рекомендуемый подход для любого плагина, поставляющего множество ресурсов admin-next (поля и, по той же логике, собственные сгруппированные эндпоинты конфигурации и начальной загрузки). Он сохраняет быструю первую отрисовку редактора независимо от числа установленных плагинов.
Контракт веб-компонента¶
Каждый JavaScript-файл должен определять пользовательский элемент, используя имя тега, переданное через window.__GRAV_FIELD_TAG:
const TAG = window.__GRAV_FIELD_TAG;
class YourFieldType extends HTMLElement {
// Свойства, устанавливаемые Admin2
set field(f) { this._field = f; this._render(); }
set value(v) { this._value = v; this._render(); }
get value() { return this._value; }
connectedCallback() {
this.attachShadow({ mode: 'open' });
this._render();
}
_render() {
// Постройте свой интерфейс в this.shadowRoot
}
_emitChange(newValue) {
this.dispatchEvent(new CustomEvent('change', {
detail: newValue,
bubbles: true
}));
}
}
customElements.define(TAG, YourFieldType);
Свойства (устанавливаются Admin2):
- field — объект определения поля схемы (label, help, options, validate и т. д.)
- value — текущее значение поля
События (испускаются вашим компонентом):
- change — CustomEvent с detail, равным новому значению
Внедряемые глобальные переменные¶
Перед выполнением вашего скрипта поля Admin2 устанавливает небольшой набор глобальных переменных в window. Это весь интерфейс между вашим компонентом и средой админки. Первые четыре внедряются непосредственно перед запуском вашего скрипта; остальные устанавливаются один раз при запуске приложения и доступны каждому компоненту.
| Глобальная переменная | Описание |
|---|---|
window.__GRAV_FIELD_TAG |
Имя тега пользовательского элемента, назначенное этому полю, в виде grav-{plugin}--{fieldType}. Всегда вызывайте customElements.define() с этим значением. |
window.__GRAV_API_SERVER_URL |
Базовый URL сайта Grav (например, https://my-site.example). |
window.__GRAV_API_PREFIX |
Префикс API (по умолчанию /api/v1). |
window.__GRAV_API_TOKEN |
Токен доступа JWT, уже полученный Admin2, готовый к отправке как X-API-Token. |
window.__GRAV_API |
Собственный клиент API Admin2: get(path, params), post, patch, put, delete(path, body). Предпочитайте его собственному fetch() (см. Доступ к API). |
window.__GRAV_DIALOGS |
Помощник диалогов Admin2: confirm() для «да/нет», плюс form() и open() для более богатых модальных окон (см. Модальные окна и наложения). Используйте его вместо нативных confirm()/alert()/prompt(). |
window.__GRAV_ADMIN_BASE |
Базовый путь Admin2, полезен для построения внутренних ссылок. |
window.__GRAV_NAVIGATE |
Функция навигации в SPA (откатывается к window.location.href, если отсутствует). |
window.__GRAV_I18N |
Мост локали и направления: __GRAV_I18N.dir равно 'ltr' или 'rtl', а __GRAV_I18N.subscribe(fn) срабатывает при смене языка. См. RTL и интернационализация. |
window.__GRAV_CONTENT_LANG |
Активный язык контента. |
Примечание
Для компонентов полей нет глобальной переменной window.__GRAV_ENVIRONMENT. Admin2 управляет активным окружением через свою аутентифицированную сессию, поэтому из поля не нужно отправлять заголовок X-Grav-Environment. Если вы видели этот заголовок в старом коде полей, он ничего не делал.
Доступ к API¶
Примечание
Требуется Admin2 2.1.35 или новее.
Самый простой способ — window.__GRAV_API, который отправляет ваш вызов через собственный клиент API Admin2. Пути задаются относительно префикса API, вызов разрешает data ответа, а ошибка отклоняется с объектом ошибки, несущим status и серверное message:
// после того как ваш эндпоинт изменил запись, на которой находится это поле
try {
const stock = await window.__GRAV_API.post('/my-plugin/stock/42/add', { qty: 3 });
window.__GRAV_TOAST?.success(`Stock is now ${stock.qty}`);
} catch (err) {
window.__GRAV_TOAST?.error(err.message);
}
Вызов получает всё, что получают собственные вызовы Admin2: свежий токен (тот, что в __GRAV_API_TOKEN, истекает через час), запрос повторного входа, когда сессия закончилась, выбор из списка окружений, повтор для хостов, отклоняющих PATCH/PUT/DELETE, и заголовок ответа X-Invalidates. Последнее означает, что эндпоинт, отвечающий через $this->respondWithInvalidation($data, ['flex-objects:' . $type . ':update:' . $key]), обновляет экран редактирования, показывающий эту запись, на месте, без перезагрузки страницы. Если в форме есть несохранённые изменения, её не трогают и вместо этого показывают уведомление «изменено в другом месте», как и когда запись сохраняет кто-то другой. Теги, которые слушает каждый экран: pages:update:<route>, config:update:<scope>, plugins:update:<slug>, users:update:<username> и flex-objects:<type>:update:<key>.
Если вам нужен необработанный Response (скачивание файла, потоковое тело), внедрённые глобальные переменные позволяют вызвать fetch() самостоятельно:
Ваш веб-компонент может вызывать любой эндпоинт API, включая пользовательские эндпоинты, которые регистрирует ваш плагин. Аутентификация уже обработана за вас через внедрённые глобальные переменные:
function apiUrl(path) {
const base = window.__GRAV_API_SERVER_URL || '';
const prefix = window.__GRAV_API_PREFIX || '/api/v1';
return `${base}${prefix}${path}`;
}
function apiHeaders(json = false) {
const headers = {};
const token = window.__GRAV_API_TOKEN;
// Используйте X-API-Token вместо Authorization: Bearer. FastCGI / PHP-FPM / CGI
// (частый виновник — mod_fastcgi в MAMP) молча удаляют заголовок
// Authorization до того, как он дойдёт до PHP; заголовки X-* проходят
// без помех. Сервер также принимает Authorization: Bearer как запасной вариант.
if (token) headers['X-API-Token'] = token;
if (json) headers['Content-Type'] = 'application/json';
return headers;
}
async function apiGet(path) {
const resp = await fetch(apiUrl(path), { headers: apiHeaders() });
const json = await resp.json();
return json.data || json;
}
Light DOM и Shadow DOM¶
Вы можете рендерить своё поле в light DOM (this.innerHTML) или в shadow root (this.attachShadow({ mode: 'open' })). Компромисс:
- Light DOM автоматически наследует типографику и цветовые токены Admin2, поэтому простым полям только для чтения или текстовым полям почти не нужны стили. Цена — стили хост-страницы могут просочиться в вашу разметку.
- Shadow DOM даёт полную изоляцию стилей, что нужно для всего со значительным пользовательским интерфейсом. Цена — вы предоставляете весь свой CSS внутри блока
<style>, а токены темы Admin2 не наследуются (читайте их явно через пользовательские свойства CSS, напримерvar(--foreground),var(--background),var(--border),var(--primary),var(--muted-foreground)).
Как правило: дисплеям только для чтения обычно нужен light DOM; интерактивным средствам выбора обычно нужен Shadow DOM.
Диалоги: никогда не используйте нативные confirm() / alert() / prompt()¶
Нативные диалоги браузера ломают визуальный язык Admin2 и блокируют цикл событий. Вместо этого Admin2 предоставляет window.__GRAV_DIALOGS для подтверждений:
async _deleteItem(slug) {
const ok = await window.__GRAV_DIALOGS?.confirm({
title: 'Delete item?',
message: `"${slug}" will be permanently removed. This cannot be undone.`,
confirmLabel: 'Delete',
variant: 'destructive', // добавляет значок предупреждения и красную кнопку подтверждения
});
if (!ok) return;
// ... продолжаем
}
Сигнатура:
window.__GRAV_DIALOGS.confirm({
title?: string; // по умолчанию: "Are you sure?"
message: string; // обязательный текст тела
confirmLabel?: string; // по умолчанию: "Confirm"
cancelLabel?: string; // по умолчанию: "Cancel"
variant?: 'destructive' | 'default';
}): Promise<boolean> // true при подтверждении, false при отмене / Escape / клике по фону
Всегда используйте необязательную цепочку (?.), чтобы ваш компонент корректно деградировал, если его когда-либо загрузят вне Admin2. Ограничивайте message одним-двумя предложениями: существительное — в title («Delete page?»), последствие — в message («All revisions will be lost.»).
Модальные окна и наложения¶
Тот же объект window.__GRAV_DIALOGS, который предоставляет confirm(), также открывает более богатые модальные окна, поэтому вручную их обычно писать не нужно. Оба метода разрешаются результатом или null, если пользователь закрывает модальное окно (Escape, фон или «Отмена»), и одновременно показывается только одно модальное окно — лишние вызовы встают в очередь.
Примечание
Требуется grav-plugin-api 1.0.0-rc.16 / Admin2 2.0.0-rc.16 или новее. В более старых сборках есть только __GRAV_DIALOGS.confirm().
form() — форма со встроенными полями, без компонента. Определите поля в JS и получите значения обратно:
const values = await window.__GRAV_DIALOGS.form({
title: 'New Article',
fields: [
{ name: 'title', label: 'Title', required: true },
{ name: 'section', label: 'Section', type: 'select',
options: [{ value: 'news', label: 'News' }, { value: 'blog', label: 'Blog' }] },
{ name: 'pinned', label: 'Pinned', type: 'toggle' }, // text | textarea | select | toggle | number
],
submitLabel: 'Create',
size: 'md', // sm | md | lg | xl
});
if (values) { /* { title, section, pinned } — например, POST /pages */ }
open() — собственный веб-компонент модального окна. Поставьте его в admin-next/modals/{id}.js (монтируется как grav-{slug}--modal-{id}, отдаётся через GET /gpm/plugins/{slug}/modal-script/{id}):
const result = await window.__GRAV_DIALOGS.open({
plugin: 'my-plugin',
component: 'my-modal', // → admin-next/modals/my-modal.js
title: 'My Modal',
props: { route: '/blog' }, // устанавливаются как свойства элемента
size: 'lg',
});
Внутри компонента верните результат, испустив resolve (со своим значением в detail), или закройте через cancel / close — тот же приём, что у события close плавающего виджета:
Примечание
Для «создать страницу типа X внутри Y» модальное окно обычно вообще не нужно — используйте глубокую ссылку на собственную форму новой страницы Admin2. См. Элементы и действия панели меню о намерении route и параметрах запроса /pages/new.
Наложения, написанные вручную (запасной вариант). Если вам нужно наложение, которое не покрывает ни один метод, добавляйте его в document.body, а не в shadow DOM, чтобы избежать ограничений переполнения от макета формы:
_openModal() {
const modal = document.createElement('div');
modal.id = '__my-plugin-modal';
modal.innerHTML = `<style>...</style><div class="modal">...</div>`;
document.body.appendChild(modal);
}
_closeModal() {
document.getElementById('__my-plugin-modal')?.remove();
}
Предупреждение
При рендеринге в document.body на ваш CSS будут влиять стили хост-страницы (включая Tailwind CSS). Используйте уникальные префиксы классов и явные значения свойств, чтобы избежать конфликтов. В частности, Tailwind v4 задаёт * { min-height: 0 }, что может схлопнуть элементы — добавьте min-height: auto своим контейнерам.
RTL и интернационализация¶
Admin2 работает как слева направо, так и справа налево, следуя языку админки пользователя (арабский, иврит, персидский, урду и любой другой, помеченный как RTL). Компоненты полей должны учитывать активное направление.
Контракт живёт в window.__GRAV_I18N:
window.__GRAV_I18N.dir // 'ltr' | 'rtl' — снимок только для чтения
window.__GRAV_I18N.subscribe(fn) // срабатывает при смене локали (и направления); возвращает функцию отписки
Также устанавливается <html dir>, поэтому всё в обычном каскаде CSS получает направление бесплатно. Внутри Shadow DOM вы читаете его явно:
_getDir() {
if (window.__GRAV_I18N?.dir) return window.__GRAV_I18N.dir;
return document.documentElement.getAttribute('dir') === 'rtl' ? 'rtl' : 'ltr';
}
connectedCallback() {
this._render();
// Admin2 может сменить язык без полной перезагрузки — применяйте заново при изменении.
this._i18nUnsub = window.__GRAV_I18N?.subscribe?.(() => this._applyDir());
}
disconnectedCallback() {
this._i18nUnsub?.();
}
Предпочитайте логические свойства CSS (padding-inline-start, margin-inline-end, inset-inline-start, text-align: start), чтобы одно правило работало в обоих направлениях. Привязывайте любой встроенный редактор кода или исходного кода к dir="ltr" независимо от направления админки, так как код всегда идёт слева направо.
Доступность¶
Относитесь к своему пользовательскому элементу как к любому другому интерактивному элементу управления. Используйте семантический HTML (<button>, а не кликабельный <div>), подключайте обработчики клавиатуры, задавайте aria-label / aria-describedby там, где уместно, и уважайте prefers-reduced-motion для анимаций. Если ваше поле интерактивно и использует Shadow DOM, проверьте его хотя бы с одним экранным диктором.
Поддержка классической админки и Admin2¶
Плагинам, которым нужно продолжать работать в классической админке Twig/jQuery (для сайтов, всё ещё на Grav 1.7), а также в Admin2, какое-то время придётся нести две реализации поля. Основные плагины команды держат их отдельными файлами, а не делят общий слой. Плагин codesh, например, поставляет admin/js/codeshtheme-field.js для классической админки и admin-next/fields/codeshtheme.js для Admin2 рядом друг с другом:
your-plugin/
admin/
js/
codeshtheme-field.js # Классическая админка: интерфейс на jQuery
admin-next/
fields/
codeshtheme.js # Admin2: интерфейс на веб-компоненте
Для большинства полей такое небольшое дублирование — самый простой путь. Если у поля существенная общая логика (валидация ввода, разбор данных, нетривиальный клиент API), вы можете вынести её в обычный ESM-модуль внутри плагина и импортировать с обеих сторон: сторона веб-компонента импортирует его во время выполнения, классическая сторона собирает его той сборкой, которую уже использует. Новый инструментарий не нужен ни в том, ни в другом случае.
Реальный пример: подсветка синтаксиса кода¶
Плагин Codesh предоставляет два пользовательских типа полей как эталонную реализацию:
Пользовательские эндпоинты API¶
Codesh регистрирует собственные эндпоинты для управления темами и грамматиками:
// В codesh.php
public function onApiRegisterRoutes(Event $event): void
{
$routes = $event['routes'];
$routes->get('/codesh/themes', [ApiController::class, 'themes']);
$routes->post('/codesh/themes/import', [ApiController::class, 'importTheme']);
$routes->delete('/codesh/themes/{name}', [ApiController::class, 'deleteTheme']);
$routes->get('/codesh/grammars', [ApiController::class, 'grammars']);
$routes->post('/codesh/grammars/import', [ApiController::class, 'importGrammar']);
$routes->delete('/codesh/grammars/{slug}', [ApiController::class, 'deleteGrammar']);
}
Пользовательское поле: выбор темы (codeshtheme)¶
Визуальный селектор темы с карточками предпросмотра кода:
- Файл:
admin-next/fields/codeshtheme.js - Использование в схеме:
type: codeshthemeсvariant: darkилиvariant: light - Фичи: модальная сетка с более чем 62 темами, предпросмотр кода с подсветкой синтаксиса, поиск, фильтры dark/light/custom, импорт и удаление пользовательских тем
- Вызовы API:
GET /codesh/themes,POST /codesh/themes/import,DELETE /codesh/themes/{name}
# В blueprints.yaml
theme_dark:
type: codeshtheme
label: Dark Theme
help: Syntax highlighting theme for dark mode
variant: dark
default: helios-dark
Пользовательское поле: список грамматик (codeshgrammarlist)¶
Многоколоночный список доступных грамматик TextMate:
- Файл:
admin-next/fields/codeshgrammarlist.js - Использование в схеме:
type: codeshgrammarlist - Фичи: адаптивный макет из 4 колонок, кнопка импорта пользовательских грамматик, удаление пользовательских записей, показ псевдонимов
- Вызовы API:
GET /codesh/grammars,POST /codesh/grammars/import,DELETE /codesh/grammars/{slug}
Ключевые приёмы из Codesh¶
- Отдельный контроллер API —
classes/ApiController.phpобрабатывает все эндпоинты REST - Повторное использование существующих менеджеров —
ThemeManagerиGrammarManagerиспользуются и классической админкой, и контроллером API - Обработка загрузки файлов — откатывается к
$_FILES, когда PSR-7getUploadedFiles()возвращает пустой результат - Модальное окно в document.body — средство выбора темы добавляет своё модальное окно в
document.body, чтобы обойти ограничения shadow DOM - Однопроходная токенизированная подсветка — использует одно регулярное выражение с группами чередования для подсветки синтаксиса, чтобы избежать самосовпадений
Пользовательские страницы админки¶
Помимо пользовательских типов полей, плагины могут регистрировать собственные полные страницы в боковой панели Admin2. Это позволяет плагинам предоставлять выделенные интерфейсы управления — например, страницу редактирования лицензий в License Manager — не изменяя сам Admin2.
Есть два режима рендеринга:
- Режим схемы — плагин предоставляет схему Grav, и Admin2 автоматически рендерит форму. Лучше всего для страниц на основе данных (настройки, редакторы «ключ-значение», панели конфигурации).
- Режим компонента — плагин предоставляет веб-компонент полной страницы. Лучше всего для полностью пользовательских интерфейсов, которые не сводятся к стандартной форме.
Регистрация в боковой панели¶
Чтобы добавить запись в боковую панель Admin2, подпишитесь на событие onApiSidebarItems и добавьте свой элемент:
public static function getSubscribedEvents()
{
return [
'onApiSidebarItems' => ['onApiSidebarItems', 0],
];
}
public function onApiSidebarItems(Event $event): void
{
$items = $event['items'] ?? [];
$items[] = [
'id' => 'license-manager',
'plugin' => 'license-manager',
'label' => 'Licenses',
'icon' => 'fa-key',
'route' => '/plugin/license-manager',
'priority' => 10,
];
$event['items'] = $items;
}
Свойства элемента боковой панели:
| Свойство | Тип | Обязательный | Описание |
|---|---|---|---|
id |
string | да | Уникальный идентификатор этого элемента боковой панели |
plugin |
string | да | Slug плагина-владельца |
label |
string | да | Отображаемое имя в боковой панели |
icon |
string | да | Класс иконки FontAwesome (например, fa-key) |
route |
string | да | Путь маршрута Admin2 (например, /plugin/license-manager) |
priority |
integer | нет | Порядок сортировки; большие значения отображаются раньше (по умолчанию: 0) |
badge |
string | нет | Статический текст или число значка рядом с меткой |
badgeEndpoint |
string | нет | Путь API, возвращающий { count: N }, для значка, обновляющегося во время работы |
authorize |
string|array | нет | Разрешение(я), необходимые для отображения элемента; массив — проверка «любое из». Удаляется до того, как элемент дойдёт до клиента |
children |
array | нет | Ярлыки экранов внутри вашего плагина, которые каждый пользователь может выбрать для показа под элементом (см. Ярлыки под элементом) |
Admin2 вызывает GET /sidebar/items при загрузке. API вызывает onApiSidebarItems, собирает все элементы плагинов и возвращает их.
Динамические значки¶
Значение badge статично — оно меняется только при полной перезагрузке боковой панели. Для счётчика, который обновляется сам (ожидающие элементы, непрочитанные сообщения и т. п.), вместо него добавьте badgeEndpoint:
$items[] = [
'id' => 'pushy',
'plugin' => 'pushy',
'label' => 'Pushy',
'icon' => 'fa-key',
'route' => '/plugin/pushy',
'priority' => 10,
'badgeEndpoint' => '/pushy/badge', // возвращает { count: N }
];
Эндпоинт возвращает число:
public function badge(ServerRequestInterface $request): ResponseInterface
{
return ApiResponse::create(['count' => $this->pendingCount()]);
}
Admin2 загружает его при загрузке боковой панели и перезагружает при изменениях контента, конфигурации, плагинов и тем. Живой счётчик переопределяет статический badge. Для немедленного обновления из вашей собственной страницы плагина или виджета — не дожидаясь события обновления — испустите событие window:
window.dispatchEvent(new CustomEvent('grav:sidebar:badge', {
detail: { id: 'pushy', count: 42 },
}));
Это тот же механизм значков, который используют контекстные панели (см. Контекстные панели).
Ярлыки под элементом¶
Плагин с несколькими экранами может предлагать их как children своего элемента боковой панели. Каждый пользователь выбирает, какие из них показывать под элементом: кто-то постоянно работает с «Заказами» и «Клиентами» и видит именно эти два экрана, а кто-то — те, которыми пользуется. Пока пользователь не выбрал свои, показываются те дочерние элементы, которые вы пометили default. Это требует Admin2 2.2.0 и API 1.1.0 или новее; более старые версии игнорируют children и показывают элемент отдельно.
public function onApiSidebarItems(Event $event): void
{
$items = $event['items'] ?? [];
$items[] = [
'id' => 'my-plugin',
'plugin' => 'my-plugin',
'label' => 'My Plugin',
'icon' => 'fa-store',
'route' => '/plugin/my-plugin',
'priority' => 10,
'children' => [
['id' => 'orders', 'label' => 'PLUGIN_MY_PLUGIN.ORDERS', 'route' => '/plugin/my-plugin/orders', 'default' => true],
['id' => 'customers', 'label' => 'PLUGIN_MY_PLUGIN.CUSTOMERS', 'route' => '/plugin/my-plugin/customers', 'default' => true],
['id' => 'coupons', 'label' => 'PLUGIN_MY_PLUGIN.COUPONS', 'route' => '/plugin/my-plugin/coupons'],
['id' => 'settings', 'label' => 'PLUGIN_MY_PLUGIN.SETTINGS', 'route' => '/plugin/my-plugin/settings', 'authorize' => ['my-plugin.settings', 'api.super']],
],
];
$event['items'] = $items;
}
Свойства дочернего элемента:
| Свойство | Тип | Обязательный | Описание |
|---|---|---|---|
id |
string | да | Буквы, цифры, _ и -, до 48 символов, уникальный внутри элемента. Сохраняется в настройках каждого пользователя, поэтому не меняйте его от версии к версии |
label |
string | да | Текст, показываемый в боковой панели. Ключ перевода переводится на язык админки пользователя |
route |
string | да | Маршрут Admin2, который открывает ярлык. Используйте подмаршрут страницы плагина (/plugin/my-plugin/orders); для страницы, маршрутизируемой по хешу, подойдёт и маршрут с частью # |
default |
boolean | нет | true, чтобы показывать этот дочерний элемент, пока пользователь не выбрал свои. Учитывается только настоящее true |
icon |
string | нет | Иконка в том же формате, что и icon элемента |
authorize |
string|array | нет | Разрешение(я), необходимые для отображения дочернего элемента, — как и у элемента. Удаляется до того, как дочерний элемент дойдёт до клиента |
Несколько вещей, о которых стоит знать:
- Пользователи выбирают звёздочкой. В Admin2 карандаш внизу боковой панели показывает дочерние элементы каждого элемента, а рядом с каждым — звёздочку. Отмеченные звёздочкой дочерние элементы показываются под элементом; если снять звёздочку с последнего, элемент остаётся один. Суперадминистратор может сохранить свою расстановку как сайтовое значение по умолчанию для всех, кто ещё не настроил свою.
- Ваш порядок важнее. Ярлыки всегда показываются в том порядке, в котором вы их перечислили, а не в порядке расстановки звёздочек, поэтому перечисляйте их так же, как устроено меню вашей страницы.
- Дочерние элементы проверяются так же, как элементы. Дочерний элемент, на который у пользователя нет разрешения, отбрасывается до отправки ответа, равно как и элемент с неверным или повторяющимся
idлибо с отсутствующимиlabelилиroute. Дайте дочернему элементу то же разрешение, которое проверяет ваша страница, прежде чем показать этот экран, — тогда никому не предложат ярлык на экран, который он не может открыть. - Предлагайте экраны, на которые стоит попасть. Перечисляйте экраны из меню вашей страницы, а не редакторы отдельных записей. Можно не указывать экраны, которые на этом сайте выключены (необязательная возможность отключена), и вернуть их, когда их включат; звёздочка на дочернем элементе, который вы перестали предлагать, сохраняется и снова появляется, когда вы его вернёте.
- Где это хранится. Выборы лежат в настройке пользователя
sidebarLayout, в секцииshortcuts, с ключомplugin:<item id>. У пользователя, который ни разу не ставил звёздочки вашему элементу, записи нет — именно поэтому действуетdefault. Переименование элемента или дочернегоidлишает звёздочек всех.
Звёздочки на собственной странице¶
Если у страницы вашего плагина есть собственное меню, можно поставить такие же звёздочки рядом с его строками — тогда пользователи отмечают экран там, где им удобнее им пользоваться, а не только из карандаша боковой панели. Для этого Admin2 предоставляет window.__GRAV_SIDEBAR:
| Вызов | Возвращает | Описание |
|---|---|---|
children(itemId) |
string[] |
Идентификаторы дочерних элементов, которые ваш элемент предлагает этому пользователю, после проверки authorize. Рисуйте звёздочку только рядом с такими строками |
shortcuts(itemId) |
string[] |
Идентификаторы, которые показываются под элементом сейчас: выбор пользователя или ваши значения по умолчанию, если у него их нет |
setShortcut(itemId, childId, on) |
Promise<void> |
Поставить (true) или снять (false) звёздочку с одного дочернего элемента. Идентификатор, который элемент этому пользователю не предлагает, игнорируется |
itemId — это id вашего элемента боковой панели (my-plugin) без префикса plugin:. Изменение сразу видно в боковой панели и сохраняется в учётной записи пользователя; если сохранение не удалось, Admin2 оставляет изменение и повторит попытку при следующем сохранении.
Всегда, когда ярлыки меняются — с вашей страницы, из карандаша боковой панели или от другого плагина, — Admin2 отправляет событие grav:sidebar:shortcuts в window. Его detail.shortcuts содержит показываемые идентификаторы для каждого элемента с дочерними, с ключом по id элемента. Событие также отправляется один раз при загрузке боковой панели, и это важно: ваша страница может подключиться раньше, чем придут элементы боковой панели, и тогда children() ещё возвращает пустой список.
const ITEM = 'my-plugin';
class MyPluginPage extends HTMLElement {
connectedCallback() {
this._onShortcuts = () => this.paintStars();
window.addEventListener('grav:sidebar:shortcuts', this._onShortcuts);
this.render();
}
disconnectedCallback() {
window.removeEventListener('grav:sidebar:shortcuts', this._onShortcuts);
}
render() {
// ... рисуем меню, а рядом с каждой строкой — скрытую <button data-star="orders">
this.querySelectorAll('[data-star]').forEach((star) => {
star.addEventListener('click', () => this.toggleStar(star.dataset.star));
});
this.paintStars();
}
paintStars() {
const sidebar = window.__GRAV_SIDEBAR;
const offered = new Set(sidebar ? sidebar.children(ITEM) : []);
const shown = new Set(sidebar ? sidebar.shortcuts(ITEM) : []);
this.querySelectorAll('[data-star]').forEach((star) => {
const id = star.dataset.star;
star.hidden = !offered.has(id); // старый Admin2 или дочерний элемент, которого пользователю не предлагают
star.setAttribute('aria-pressed', String(shown.has(id)));
});
}
async toggleStar(id) {
const on = !window.__GRAV_SIDEBAR.shortcuts(ITEM).includes(id);
await window.__GRAV_SIDEBAR.setShortcut(ITEM, id, on); // событие перерисовывает звёздочки
}
}
Рисуйте звёздочки скрытыми и показывайте их из paintStars(), чтобы старый Admin2 без window.__GRAV_SIDEBAR никогда не показывал звёздочку, которая ничего не делает. Задайте каждой звёздочке aria-label вроде "Show Orders in the admin sidebar", а если меню перерисовывается часто (счётчик, который обновляется, поле фильтра), вызывайте paintStars() после каждой перерисовки и скрывайте звёздочку строки вместе с самой строкой.
Держите список children в PHP и меню своей страницы в соответствии. Модульный тест, который читает оба и падает, когда у строки меню нет дочернего элемента или у дочернего элемента нет строки, поймает расхождение до выпуска.
Определение страницы¶
Когда пользователь переходит на страницу плагина, Admin2 вызывает GET /gpm/plugins/{slug}/page, чтобы получить определение страницы. Подпишитесь на onApiPluginPageInfo, чтобы предоставить его:
public static function getSubscribedEvents()
{
return [
'onApiPluginPageInfo' => ['onApiPluginPageInfo', 0],
];
}
public function onApiPluginPageInfo(Event $event): void
{
if ($event['plugin'] !== 'license-manager') {
return;
}
$event['definition'] = [
'id' => 'license-manager',
'plugin' => 'license-manager',
'title' => 'License Manager',
'icon' => 'fa-key',
'page_type' => 'blueprint',
'blueprint' => 'licenses',
'data_endpoint' => '/licenses/form-data',
'save_endpoint' => '/licenses',
'actions' => [
[
'id' => 'import',
'label' => 'Import',
'icon' => 'fa-upload',
'upload' => true,
'endpoint' => '/licenses/import',
],
[
'id' => 'export',
'label' => 'Export',
'icon' => 'fa-download',
'download' => true,
'endpoint' => '/licenses/export',
],
[
'id' => 'save',
'label' => 'Save',
'icon' => 'fa-check',
'primary' => true,
],
],
];
}
Примечание
Всегда проверяйте $event['plugin'] перед установкой определения. Каждый плагин, слушающий onApiPluginPageInfo, получает каждый запрос — отвечайте только когда slug совпадает с вашим.
Режим схемы¶
Установите page_type в 'blueprint' и предоставьте:
| Свойство | Описание |
|---|---|
blueprint |
Имя файла схемы (без .yaml) в admin/blueprints/ |
data_endpoint |
Путь API, возвращающий текущие данные в формате, совместимом со схемой |
save_endpoint |
Путь API, принимающий PATCH с данными формы |
Admin2 загружает схему через GET /blueprints/plugins/{plugin}/pages/{pageId}, загружает текущие данные из data_endpoint, рендерит форму и отправляет сохранения в save_endpoint.
Файл схемы лежит в стандартном месте Grav:
Режим компонента¶
Установите page_type в 'component' и разместите JavaScript-файл по пути:
Admin2 загружает скрипт через GET /gpm/plugins/{slug}/page-script, задаёт имя тега через window.__GRAV_PAGE_TAG и монтирует элемент в области содержимого. Те же глобальные переменные (__GRAV_API_SERVER_URL, __GRAV_API_PREFIX, __GRAV_API_TOKEN) доступны для вызовов API.
Вы также можете использовать оба режима вместе: установить page_type в 'blueprint' и дополнительно поставлять файл pages/{slug}.js. Ответ API будет содержать has_custom_component: true, позволяя Admin2 рендерить форму схемы рядом с разделами пользовательского компонента.
Несколько представлений на одной странице: подмаршруты¶
Страница плагина также отвечает на каждый путь ниже собственного, поэтому /plugin/my-plugin/orders и /plugin/my-plugin/orders/42 рендерят ту же страницу, что и /plugin/my-plugin (Admin2 2.1.34 и новее). Когда у плагина несколько представлений, дайте каждому свою запись в боковой панели с путевым маршрутом, а не строку запроса ?view=. Боковая панель подсвечивает ссылку на её собственном пути и всём ниже, поэтому /plugin/my-plugin/orders остаётся подсвеченной на /plugin/my-plugin/orders/42, но не на /plugin/my-plugin/customers.
['id' => 'my-plugin-orders', 'route' => '/plugin/my-plugin/orders', 'label' => 'Orders', 'icon' => 'fa-receipt', 'plugin' => 'my-plugin'],
['id' => 'my-plugin-customers', 'route' => '/plugin/my-plugin/customers', 'label' => 'Customers', 'icon' => 'fa-users', 'plugin' => 'my-plugin'],
- Элемент вашего компонента получает всё после
/plugin/{slug}/как атрибутpathи свойствоpath, напримерordersилиorders/42, и пустую строку на самой странице. - Переход между представлениями одного плагина сохраняет ваш элемент смонтированным. Admin2 обновляет
pathи испускает на элементе событиеroute-changeсdetail = { path, search }. Оно также срабатывает, когда меняется только строка запроса, поэтому следить заlocationсамостоятельно не нужно. - Чтобы перейти между представлениями изнутри компонента, вызовите
window.__GRAV_NAVIGATE(window.__GRAV_ADMIN_BASE + '/plugin/my-plugin/customers').
connectedCallback() {
this._show(this.getAttribute('path') || 'orders');
this.addEventListener('route-change', (e) => this._show(e.detail.path || 'orders'));
}
Ссылки, различающиеся только строкой запроса, по-прежнему работают: ссылка в боковой панели с ?view=orders подсвечивается, только пока в текущем URL есть view=orders.
Связь с панелью инструментов¶
У страницы в режиме компонента нет формы схемы, поэтому Admin2 не может отслеживать её изменения так, как для страниц схемы. Вместо этого компонент и панель инструментов заголовка общаются через два события DOM, испускаемых на собственном элементе компонента:
page-action(панель инструментов → компонент) — Admin2 испускает его, когда нажата кнопка панели инструментов, принадлежащая вашему компоненту: любое действие безendpoint, включая действиеprimary.detailсобытия —{ id, label }.page-state(компонент → панель инструментов) — ваш компонент испускает его, чтобы сообщить своё состояние, которое управляет кнопкой действияprimary.detailпринимает{ dirty, valid, busy }(все ключи необязательны, объединяются при каждом испускании):dirty— установитеtrue, когда появились несохранённые изменения. Кнопкаprimaryостаётся отключённой, пока компонент не сообщитdirty: true.valid— установитеfalse, чтобы держать кнопкуprimaryотключённой, пока ввод компонента недействителен.busy— установитеtrue, чтобы показать индикатор на кнопкеprimary, пока идёт сохранение.
class MyPluginPage extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
// Запускаем сохранение, когда срабатывает основное действие (или любое действие без endpoint)
this.addEventListener('page-action', (e) => {
if (e.detail?.id === 'save') this._save();
});
this._render();
}
_onChange() {
// Включаем основную кнопку Save
this.dispatchEvent(new CustomEvent('page-state', { detail: { dirty: true } }));
}
async _save() {
this.dispatchEvent(new CustomEvent('page-state', { detail: { busy: true } }));
try {
await this._persist();
window.__GRAV_TOAST?.success('Saved');
// Снова отключаем Save до следующей правки
this.dispatchEvent(new CustomEvent('page-state', { detail: { dirty: false, busy: false } }));
} catch (err) {
window.__GRAV_TOAST?.error('Save failed');
this.dispatchEvent(new CustomEvent('page-state', { detail: { busy: false } }));
}
}
}
Компонент, который никогда не испускает page-state, оставляет кнопку primary навсегда отключённой, поэтому страница-компонент, которой нужна работающая кнопка Save, должна хотя бы один раз сообщить dirty: true.
Всплывающие сообщения и диалоги доступны глобально любому веб-компоненту, поэтому свои писать не нужно:
window.__GRAV_TOAST.{success,error,info,warning}(message, options)—optionsпередаются в компонент всплывающих сообщений (например,{ duration: 6000 }).window.__GRAV_DIALOGS.confirm({ title, message, variant })— возвращаетPromise<boolean>. Используйтеvariant: 'destructive'для действий удаления и очистки. Никогда не используйте нативныеconfirm()/alert()/prompt().
Кнопки действий¶
Массив actions определяет кнопки, рендерящиеся в панели инструментов заголовка страницы. Каждое действие — объект со следующими свойствами:
| Свойство | Тип | Описание |
|---|---|---|
id |
string | Уникальный идентификатор действия |
label |
string | Текст кнопки |
icon |
string | Класс иконки FontAwesome |
primary |
boolean | Если true, это основное действие сохранения. В режиме схемы оно отправляет данные формы в save_endpoint. В режиме компонента оно испускает событие page-action вашему компоненту и включено, только пока компонент сообщает dirty: true через page-state (см. Связь с панелью инструментов) |
upload |
boolean | Если true, клик открывает выбор файла и отправляет файл POST-запросом в endpoint |
download |
boolean | Если true, клик запускает скачивание файла из endpoint |
endpoint |
string | Путь API для действий загрузки и скачивания |
confirm |
string | Если задано, перед выполнением показывается диалог подтверждения с этим сообщением |
У страницы обычно есть одна основная кнопка сохранения primary плюс необязательные действия импорта, экспорта или пользовательские действия.
Реальный пример: License Manager¶
Плагин license-manager — полная эталонная реализация пользовательской страницы админки в режиме схемы.
Обработчики событий¶
Плагин подписывается на три события:
public static function getSubscribedEvents()
{
return [
'onPluginsInitialized' => ['onPluginsInitialized', 0],
'onApiRegisterRoutes' => ['onApiRegisterRoutes', 0],
'onApiSidebarItems' => ['onApiSidebarItems', 0],
'onApiPluginPageInfo' => ['onApiPluginPageInfo', 0],
];
}
onApiRegisterRoutes— регистрирует эндпоинты REST для CRUD лицензий, импорта, экспорта и состояния продуктовonApiSidebarItems— добавляет запись «Licenses» в боковую панельonApiPluginPageInfo— возвращает определение страницы со ссылкой на схему, эндпоинтами данных и сохранения и действиями импорта и экспорта
Эндпоинты API¶
LicenseApiController предоставляет следующие эндпоинты:
| Метод | Путь | Описание |
|---|---|---|
| GET | /licenses/form-data |
Возвращает данные лицензий в формате, совместимом со схемой (используется data_endpoint) |
| PATCH | /licenses |
Сохраняет все лицензии из формы (используется save_endpoint) |
| POST | /licenses/import |
Импортирует файл licenses.yaml (действие загрузки) |
| GET | /licenses/export |
Скачивает licenses.yaml (действие скачивания) |
| GET | /licenses/products-status |
Возвращает состояние установки лицензированных продуктов |
Пользовательское поле: состояние продуктов¶
Схема включает пользовательский тип поля products-status, который выводит доступный только для чтения список лицензированных продуктов с их состоянием установки:
# admin/blueprints/licenses.yaml
form:
validation: loose
fields:
licenses:
type: array
style: vertical
placeholder_key: PLUGIN_LICENSE_MANAGER.SLUG
placeholder_value: PLUGIN_LICENSE_MANAGER.LICENSE
products_status:
type: products-status
style: vertical
Веб-компонент (admin-next/fields/products-status.js) вызывает GET /licenses/products-status и рендерит каждый продукт с его состоянием (включён, отключён, установлен или не установлен). Аутентификация использует глобальную переменную window.__GRAV_API_TOKEN, отправляемую в заголовке запроса X-API-Token.
Как всё соединяется¶
- Admin2 загружается и вызывает
GET /sidebar/items— license-manager добавляет свою запись «Licenses» - Пользователь нажимает элемент боковой панели, Admin2 переходит на
/plugin/license-manager - Admin2 вызывает
GET /gpm/plugins/license-manager/page— плагин возвращает определение своей страницы - Admin2 видит
page_type: 'blueprint', загружает схему изGET /blueprints/plugins/license-manager/pages/licenses - Admin2 загружает текущие данные из
GET /licenses/form-data - Форма рендерится со стандартными полями (array для лицензий) и пользовательским полем (веб-компонент products-status)
- Кнопка Save отправляет PATCH в
/licenses; Import и Export вызывают свои эндпоинты
Другие точки расширения Admin2¶
Помимо полных страниц плагинов и пользовательских полей, у Admin2 есть несколько меньших точек расширения, в которые плагины могут вносить вклад. Каждая следует одному шаблону: GET-эндпоинт, вызывающий событие, плагины добавляют элементы в данные события, а Admin2 рендерит собранные результаты. Данные веб-компонентов (где применимо) лежат в admin-next/{subdir}/{slug}.js внутри плагина и отдаются по требованию выделенными маршрутами скриптов.
| Точка расширения | Эндпоинт | Событие регистрации | Веб-компонент (если есть) | Маршрут скрипта |
|---|---|---|---|---|
| Полная страница плагина | GET /gpm/plugins/{slug}/page |
onApiPluginPageInfo |
admin-next/pages/{slug}.js |
GET /gpm/plugins/{slug}/page-script |
| Пользовательское поле схемы | (автоматическое обнаружение) | (файловая система) | admin-next/fields/{type}.js |
GET /gpm/plugins/{slug}/field/{type} |
| Элемент боковой панели | GET /sidebar/items |
onApiSidebarItems |
(нет — ссылка на страницу плагина) | — |
| Панель настроек | GET /settings/panels |
onApiAdminSettingsPanels |
(только режим схемы) | — |
| Элемент панели меню | GET /menubar/items |
onApiMenubarItems |
(нет — POST на эндпоинт действия) | — |
| Действие панели меню | POST /menubar/actions/{plugin}/{action} |
onApiMenubarAction |
— | — |
| Плавающий виджет | GET /floating-widgets |
onApiFloatingWidgets |
admin-next/widgets/{slug}.js |
GET /gpm/plugins/{slug}/widget-script |
| Контекстная панель | GET /context-panels |
onApiContextPanels |
admin-next/panels/{slug}.js |
GET /gpm/plugins/{slug}/panel-script |
| Пользовательский отчёт | GET /reports |
onApiGenerateReports |
admin-next/reports/{reportId}.js |
GET /gpm/plugins/{slug}/report-script/{reportId} |
| Виджет панели управления | GET /dashboard/widgets |
onApiDashboardWidgets |
собственный скрипт плагина, названный в scriptUrl виджета |
собственный маршрут плагина |
| Кнопка редактора markdown | GET /editor/toolbar-buttons |
onApiMarkdownEditorButtons |
— | — |
Текущий аутентифицированный пользователь передаётся в данных события для всех них, кроме onApiGenerateReports. Используйте его, чтобы пропускать регистрации, когда у пользователя нет разрешений, необходимых для этой функции. Элементы боковой панели, элементы панели меню, плавающие виджеты, контекстные панели и кнопки редактора также могут объявлять authorize (строка разрешения или массив для проверки «любое из»), а виджеты панели управления могут объявлять его как одну строку разрешения: API отбрасывает записи, на которые пользователь не авторизован, и удаляет authorize до отправки ответа. Панели настроек и отчёты не имеют проверки authorize, поэтому ограничивайте их в своём слушателе.
Маршруты скриптов страниц, виджетов, панелей, модальных окон и отчётов возвращают 404 для выключенного плагина, поэтому страницы админки и интерфейс выключенного плагина перестают загружаться. Скрипты пользовательских полей (/field/{type} и /fields) по-прежнему отдаются для выключенного плагина, потому что его форма настроек редактируется до включения и может использовать собственные типы полей. Все эти маршруты скриптов учитываются в ограничении частоты запросов API, как и любой другой запрос.
Панели настроек¶
Панели настроек рендерятся как карточки внутри страницы настроек Admin2, а не как отдельные записи в боковой панели. Используйте их для конфигурации, которая относится к другим системным настройкам. Формат данных тот же, что у определения страницы плагина в режиме схемы — файл схемы плюс data_endpoint / save_endpoint — поддержки компонентов нет.
public static function getSubscribedEvents()
{
return [
'onApiAdminSettingsPanels' => ['onApiAdminSettingsPanels', 0],
];
}
public function onApiAdminSettingsPanels(Event $event): void
{
$user = $event['user'];
if (!$user->authorize('api.config.write')) {
return;
}
$panels = $event['panels'] ?? [];
$panels[] = [
'id' => 'login-settings',
'plugin' => 'my-plugin',
'label' => 'Login & Security',
'description' => 'Authentication timeouts and 2FA policy.',
'icon' => 'fa-shield-alt',
'blueprint' => 'login-settings',
'data_endpoint' => '/my-plugin/login-settings/data',
'save_endpoint' => '/my-plugin/login-settings/save',
'priority' => 10,
];
$event['panels'] = $panels;
}
Панели сортируются по priority по убыванию, а при равенстве — по порядку вставки.
Элементы и действия панели меню¶
Элементы панели меню — кнопки в один клик в верхней панели инструментов Admin2. Каждый элемент объявляет ключ action; когда пользователь нажимает, Admin2 отправляет POST на /menubar/actions/{plugin}/{action}, что вызывает onApiMenubarAction. Используйте это для быстрых задач вроде «прогреть кэш», «очистить opcache» или «сбросить CDN».
public static function getSubscribedEvents()
{
return [
'onApiMenubarItems' => ['onApiMenubarItems', 0],
'onApiMenubarAction' => ['onApiMenubarAction', 0],
];
}
public function onApiMenubarItems(Event $event): void
{
$items = $event['items'] ?? [];
$items[] = [
'id' => 'warm-cache',
'plugin' => 'warm-cache',
'label' => 'Warm Cache',
'icon' => 'fa-tachometer',
'action' => 'warm',
'confirm' => 'Warm the cache now?',
];
$event['items'] = $items;
}
public function onApiMenubarAction(Event $event): void
{
if ($event['plugin'] !== 'warm-cache') {
return;
}
// Доступны $event['body'] и $event['user'].
$result = $this->warmCache();
$event['result'] = [
'status' => 'success',
'message' => "Warmed {$result['pages']} pages in {$result['duration']}s.",
];
}
Обработчики обязаны проверять $event['plugin'] перед ответом — каждый плагин, слушающий onApiMenubarAction, получает каждый запрос. Ответ в любом случае HTTP 200 с вашим result, а Admin2 показывает всплывающее уведомление об успехе или ошибке в зависимости от status. Если ни один слушатель не установил result, API отвечает 404 (обработчик не зарегистрирован). Действие, чей элемент панели меню объявляет authorize, отклоняется с 403 для вызывающей стороны, которая его не проходит, даже при прямой отправке POST.
Клиентские намерения: route и modal¶
Вместо серверного action элемент панели меню может нести клиентское намерение, которое выполняется в Admin2 без сетевого обращения. Когда присутствует route или modal, они имеют приоритет над action (confirm, если задан, всё равно выполняется первым). Оба поля проходят через API как есть, поэтому обработчик onApiMenubarAction не нужен.
public function onApiMenubarItems(Event $event): void
{
$items = $event['items'] ?? [];
// `route` выполняет навигацию в SPA. Глубокая ссылка на нативную форму новой
// страницы с заданным родителем и зафиксированным шаблоном — замена Admin2 для
// классического рецепта "custom page creation modal". /pages/new читает
// три необязательных параметра запроса: parent, template (фиксирует выбор) и title.
$items[] = [
'id' => 'new-article',
'plugin' => 'my-plugin',
'label' => 'New Article',
'icon' => 'fa-plus',
'route' => '/pages/new?parent=/blog&template=item&title=New%20Article',
];
// `modal` открывает одно из собственных модальных веб-компонентов плагина
// (admin-next/modals/{component}.js — см. "Модальные окна и наложения").
$items[] = [
'id' => 'quick-thing',
'plugin' => 'my-plugin',
'label' => 'Quick Thing',
'icon' => 'fa-bolt',
'modal' => [
'component' => 'quick-thing',
'title' => 'Quick Thing',
'size' => 'lg', // sm | md | lg | xl
// 'props' => [...], 'useStandardHeader' => false,
],
];
$event['items'] = $items;
}
Представление и размещение¶
Помимо обязательных полей, элемент принимает необязательные ключи, управляющие тем, как и где он рендерится. Все они проходят через API как есть (без белого списка) и одинаково применяются к кнопкам действий, намерениям route/modal и ссылкам href.
| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
variant |
string | нет | Цветовой акцент: default (приглушённая иконка, по умолчанию), primary, success, warning или danger. Сопоставляется с токенами темы Admin2, никогда с необработанным цветом, поэтому кнопки остаются читаемыми в светлой и тёмной теме. |
showLabel |
bool | нет | Рендерить текст label рядом с иконкой, а не использовать его только как подсказку. Превращает маленькую иконку в читаемую кнопку с меткой. По умолчанию: false. |
size |
string | нет | sm (по умолчанию, компактная иконка) или md (выше, с более просторными отступами). |
placement |
string | нет | В какой зоне панели инструментов рендерится кнопка. start (по умолчанию) — свободное место слева в заголовке, подальше от разрушительного действия «Очистить кэш»; используйте её для повседневных действий плагинов. end размещает кнопку рядом с основными действиями (View site / Clear Cache), за разделителем, для кнопок, которые действительно относятся к системному обслуживанию. Сами основные действия никогда не могут быть перемещены плагином. |
priority |
integer | нет | Порядок внутри зоны; большие значения отображаются раньше (левее). При равенстве сохраняется порядок регистрации плагинов. По умолчанию: 0. |
public function onApiMenubarItems(Event $event): void
{
$items = $event['items'] ?? [];
// Повседневное действие: с меткой, в левой зоне, раньше остальных.
$items[] = [
'id' => 'new-article',
'plugin' => 'my-plugin',
'label' => 'New Article',
'icon' => 'fa-plus',
'route' => '/pages/new?parent=/blog&template=item',
'variant' => 'primary',
'showLabel' => true,
'placement' => 'start', // левая зона, вдали от Clear Cache
'priority' => 10,
];
// Действие обслуживания, которое относится рядом с основными элементами управления.
$items[] = [
'id' => 'purge-cdn',
'plugin' => 'my-plugin',
'label' => 'Purge CDN',
'icon' => 'fa-cloud',
'action' => 'purge',
'confirm' => 'Purge the CDN cache?',
'placement' => 'end', // рядом с View site / Clear Cache
];
$event['items'] = $items;
}
Плавающие виджеты¶
Плавающие виджеты — это постоянный интерфейс — чат-ассистенты, панели живых уведомлений, ИИ-помощники, — который остаётся смонтированным при навигации по Admin2. Каждый виджет поставляет веб-компонент в admin-next/widgets/{slug}.js.
public function onApiFloatingWidgets(Event $event): void
{
$widgets = $event['widgets'] ?? [];
$widgets[] = [
'id' => 'ai-pro-chat',
'plugin' => 'ai-pro',
'label' => 'AI Assistant',
'icon' => 'bot',
'priority' => 10,
];
$event['widgets'] = $widgets;
}
Виджет может задать showFab => false, чтобы пропустить плавающую кнопку действия, и autoLoad => true, чтобы Admin2 загружал его скрипт сразу (а не при первом нажатии на плавающую кнопку), — приём, лежащий в основе расширений полей и таблиц, которые регистрируют поведение, не показывая кнопку запуска.
Автозагружаемое расширение обычно нужно только на одной странице, поэтому объявите маршруты админки, к которым оно применяется, через routes. Тогда Admin2 загружает скрипт только на совпадающем маршруте, а не на каждой странице:
$widgets[] = [
'id' => 'users-enhancer',
'plugin' => 'my-plugin',
'label' => 'Users enhancer',
'showFab' => false,
'autoLoad' => true,
'routes' => ['/users'], // загружать скрипт только в представлении Users
];
Записи routes — это внутренние маршруты SPA админки (/users, /pages, /plugin/my-plugin), сопоставляемые с собственным состоянием маршрутизатора Admin2, поэтому они не несут префикса URL админки и не могут столкнуться со страницей фронтенда с тем же именем. Сопоставление точное — вложенный маршрут вроде редактирования страницы под /pages/... не совпадёт с записью /pages. Опускание routes сохраняет прежнее поведение (загружать везде). routes ограничивает только загрузку скрипта; это не граница безопасности — проверкой безопасности остаётся authorize.
Контекстные панели¶
Контекстные панели — это выезжающие панели, вызываемые кнопками панели инструментов внутри редакторов Admin2 (например, редактора страниц). Используйте их для инструментов уровня редактора: истории правок, SEO-анализа, ИИ-подсказок или проверки ссылок.
public function onApiContextPanels(Event $event): void
{
$panels = $event['panels'] ?? [];
$panels[] = [
'id' => 'revisions',
'plugin' => 'revisions-pro',
'label' => 'Revision History',
'icon' => 'history',
'contexts' => ['pages'], // показывать только в редакторе страниц
'priority' => 10,
'width' => 900,
'badgeEndpoint' => '/revisions-pro/badge', // необязательно {count: N}
];
$event['panels'] = $panels;
}
contexts управляет тем, в каких редакторах Admin2 показывается кнопка вызова. badgeEndpoint опрашивается на ответ {count: N}, чтобы управлять числовым значком на кнопке. Панель может задать authorize (строка разрешения или массив для проверки «любое из»); API отбрасывает панели, на которые пользователь не авторизован, удаляет authorize из ответа и сортирует остальные по priority, сначала большее значение, при равенстве сохраняя порядок регистрации.
Пользовательские отчёты¶
Плагины могут добавлять карточки на страницу отчётов Admin2 — либо как предварительно отрендеренный Markdown/HTML, либо как интерактивные веб-компоненты. Веб-компоненты отчётов лежат в admin-next/reports/{reportId}.js и загружаются по требованию.
public function onApiGenerateReports(Event $event): void
{
$reports = $event['reports'] ?? [];
$reports[] = [
'id' => 'seo-summary',
'plugin' => 'seo-magic',
'title' => 'SEO Summary',
'icon' => 'fa-search',
'component' => 'seo-summary', // разрешается в admin-next/reports/seo-summary.js
'priority' => 20,
];
$event['reports'] = $reports;
}
Установите component в null, чтобы использовать стандартный рендерер Admin2 с заранее вычисленным массивом items; установите его в id, совпадающий с именем файла, чтобы поставить пользовательский веб-компонент.
Уведомления и всплывающие сообщения¶
Есть два способа, которыми плагин сообщает пользователю что-то в Admin2, и они служат разным целям:
- Уведомления постоянны и закрываемы — баннер над панелью управления или элемент в виджете уведомлений панели управления. Используйте их для постоянных условий («настроен недоверенный хост», «резервная копия просрочена»).
- Всплывающие сообщения временны — они появляются, затем исчезают. Используйте их для немедленного результата действия («Кэш прогрет», «Сохранение не удалось»).
Постоянные уведомления (onApiDashboardNotifications)¶
Слушайте onApiDashboardNotifications и добавляйте в массив notifications, который сгруппирован по месту. Место — это ключ массива, в который вы добавляете:
top— вращающийся баннер над панелью управления. Лучше всего для одного важного постоянного уведомления.dashboard(иfeed) — элементы в списке виджета уведомлений панели управления.
public static function getSubscribedEvents()
{
return [
'onApiDashboardNotifications' => ['onApiDashboardNotifications', 0],
];
}
public function onApiDashboardNotifications(Event $event): void
{
// Поднимайте уведомление, только пока условие действительно выполняется.
if ($this->hostIsTrusted()) {
return;
}
// У события нет offsetGet по ссылке — читаем, изменяем, записываем обратно.
$notifications = $event['notifications'] ?? [];
$notifications['top'][] = [
'id' => 'my-plugin-untrusted-host', // уникально; по нему работает скрытие
'date' => date('c'), // ISO 8601
'message' => $this->grav['language']->translate('PLUGIN_MY_PLUGIN.UNTRUSTED_HOST_NOTICE'),
'icon' => 'shield-alert', // имя иконки Lucide или эмодзи
'title' => 'Security', // необязательное жирное вступление
'reappear_after' => '+7 days', // необязательно; см. ниже
// 'action' => ['label' => 'Fix it', 'url' => '/admin/config/system'],
];
$event['notifications'] = $notifications;
}
Поля элемента:
| Поле | Обязательное | Описание |
|---|---|---|
id |
да | Уникальный id. По нему работают скрытие и reappear_after. Используйте буквы, цифры, ., _ и -, начиная с буквы или цифры, не более 64 символов: эндпоинт скрытия отклоняет любой другой id с 422, поэтому уведомление с таким id нельзя закрыть. |
message |
да | Текст. Встроенный Markdown рендерится (ссылки, выделение). |
date |
да | Метка времени ISO 8601 (date('c')). Показывается рядом с элементами виджета панели управления. |
icon |
нет | Имя иконки Lucide (shield-alert, kebab-case или PascalCase) или эмодзи. |
title |
нет | Жирное вступление перед сообщением. |
action |
нет | ['label' => '…', 'url' => '…'] — рендерит кнопку, открывающую URL в новой вкладке. |
link |
нет | Делает всю строку элемента виджета панели управления ссылкой (открывается в новой вкладке). |
reappear_after |
нет | Интервал strtotime() (+7 days, +1 month). После того как пользователь скрыл уведомление, оно возвращается по истечении этого интервала. Без него скрытие постоянно для этого пользователя. |
type |
нет | info, notice, warning или promo (для неизвестных значений поведение совпадает с обычным элементом). promo рендерится в виджете уведомлений как карточка с градиентом и дополнительно учитывает image и accent (purple, blue, teal, amber, rose). |
Скрытие выполняется для каждого пользователя и автоматически: когда пользователь закрывает уведомление, Admin2 отправляет POST на /dashboard/notifications/{id}/hide, что записывает время в user://data/notifications/{username}.yaml. Уведомления плагинов объединяются заново при каждом запросе (никогда не кэшируются), но всё равно проходят через эту обработку скрытия и reappear_after, поэтому уведомление остаётся скрытым, пока условие не повторится или не истечёт интервал.
Визуальная подача определяется
type,iconи (для промо)accent/image— у стандартного уведомления нет цвета по уровню, поэтому используйте понятныеiconиtitle, чтобы обозначить важность.
Временные всплывающие сообщения¶
Из веб-компонента (пользовательского поля, страницы плагина, виджета, панели, модального окна) используйте внедрённую глобальную переменную — никогда нативный alert():
window.__GRAV_TOAST.success('Settings saved');
window.__GRAV_TOAST.error('Could not reach the service', { duration: 8000 });
// также: .info(msg, opts), .warning(msg, opts). `duration` в мс; опустите для значения по умолчанию.
Из эндпоинта сохранения или действия верните подсказку для всплывающего сообщения, чтобы сервер управлял сообщением вместо стандартного сообщения Admin2 «saved». Поместите объект toast верхнего уровня (или просто строку message) в тело ответа:
return ApiResponse::create([
'toast' => [
'message' => 'Settings saved — reindexing in the background.',
'type' => 'success', // success | error | info | warning
'duration' => 8000, // мс; 0 (или dismissible: true) = остаётся до закрытия
'dismissible' => true,
],
]);
Для пути действия панели меню (onApiMenubarAction) обёртка result уже управляет всплывающим сообщением — status: 'success' показывает сообщение об успехе с message, status: 'error' показывает сообщение об ошибке:
Блокировка сохранения ошибкой¶
Чтобы остановить сохранение и сообщить пользователю причину, выбросьте исключение из контроллера. ValidationException (HTTP 422) несёт ошибки уровня полей; Admin2 показывает сообщение, указывает на проблемное поле и оставляет форму открытой, чтобы ничего не потерялось:
use Grav\Plugin\Api\Exceptions\ValidationException;
if ($problem) {
throw new ValidationException('Content failed validation.', [
['field' => 'header.markdown', 'message' => 'Unbalanced code fence.'],
]);
}
Для ошибки, не связанной с полем, с более долгим всплывающим сообщением используйте ErrorResponse с подсказкой для сообщения (пятый аргумент):
use Grav\Plugin\Api\Response\ErrorResponse;
return ErrorResponse::create(422, 'Validation failed', 'Content failed validation.', [], [
'duration' => 0, // остаётся до закрытия
'dismissible' => true,
]);
В любом случае сохранение не завершается и редактор остаётся открытым — это ответ на вопрос «показать предупреждение, но дать пользователю исправить» против «пропустить»: выбрасывайте исключение, чтобы заблокировать, возвращайте подсказку toast, чтобы проинформировать.
Объявление совместимости¶
Объявите совместимость с API в файле blueprints.yaml вашего плагина:
Это сигнализирует экосистеме, что ваш плагин: - Протестирован с плагином API - Поставляет веб-компоненты для любых пользовательских типов полей (если применимо) - Корректно работает с Admin2
Вебхуки¶
Плагин API может отправлять исходящие вебхуки для каждого события изменения. Плагинам не нужно делать ничего особенного — WebhookDispatcher в API слушает события onApi* и пересылает их на каждый настроенный URL вебхука, чей фильтр событий совпадает. Пользователи управляют вебхуками через Эндпоинты вебхуков (или интерфейс вебхуков в Admin2).
События вебхуков сопоставляются с событиями API так:
| Событие API | Событие вебхука |
|---|---|
onApiPageCreated |
page.created |
onApiPageUpdated |
page.updated |
onApiPageDeleted |
page.deleted |
onApiPageMoved |
page.moved |
onApiPageTranslated |
page.translated |
onApiPagesReordered |
pages.reordered |
onApiMediaUploaded |
media.uploaded |
onApiMediaDeleted |
media.deleted |
onApiUserCreated |
user.created |
onApiUserUpdated |
user.updated |
onApiUserDeleted |
user.deleted |
onApiConfigUpdated |
config.updated |
onApiPackageInstalled |
gpm.installed |
onApiPackageRemoved |
gpm.removed |
onApiGravUpgraded |
grav.upgraded |
POST-запросы вебхуков подписываются HMAC-SHA256 необработанного JSON-тела с секретом конкретного вебхука, отправляемым шестнадцатеричной строкой в заголовке X-Grav-Signature, вместе с X-Grav-Event (имя события вебхука) и X-Grav-Delivery (уникальный идентификатор доставки). Собственные заголовки вебхука не могут заменить эти три. Доставки журналируются для каждого вебхука; используйте GET /webhooks/{id}/deliveries, чтобы просмотреть историю.