Перейти к содержанию

Руководство разработчика API

Это руководство описывает, как разработчики плагинов могут интегрироваться с API Grav и Admin2 — новым SPA-плагином админки на Svelte 5 (работающим на API), который заменяет устаревший классический плагин Admin в Grav 2.0.

Расширение API

Любой плагин может добавлять пользовательские эндпоинты API. Полное пошаговое руководство см. в разделе Интеграция плагинов с API.

Краткая версия:

  1. Подпишитесь на onApiRegisterRoutes в своём плагине
  2. Зарегистрируйте маршруты, указывающие на класс контроллера
  3. Создайте контроллер, расширяющий 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} для плагина), поэтому поле, предоставленное темой, загружается корректно при использовании в любой схеме.

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

  1. Admin2 встречает в схеме неизвестный тип поля
  2. Он проверяет, объявил ли плагин пользовательские поля в своём ответе API
  3. Если да, он загружает набор полей плагина из API — все скрипты полей этого плагина одним запросом (см. Загрузка, объединение и кэширование)
  4. JavaScript определяет пользовательский элемент
  5. Admin2 монтирует элемент и общается через свойства и события

Соглашение о файлах

Размещайте JavaScript-файлы веб-компонентов по пути:

your-plugin/
  admin-next/
    fields/
      yourfieldtype.js      # Один JS-файл на каждый пользовательский тип поля

Когда Admin2 загружает страницу сведений о плагине, API автоматически находит файлы в admin-next/fields/ и включает их в ответ:

{
  "slug": "your-plugin",
  "custom_fields": {
    "yourfieldtype": "yourfieldtype"
  }
}

Загрузка, объединение и кэширование

Вам не нужно ничего делать, чтобы включить это, но стоит понимать, как Admin2 на самом деле загружает ваши скрипты полей, — потому что из этого следует одно правило, которое нужно соблюдать.

Один запрос на плагин, а не на поле. Когда схема выводит на одном экране несколько ваших пользовательских полей, Admin2 не загружает их по одному. Он запрашивает весь набор полей плагина одним вызовом:

GET /api/v1/gpm/{plugins|themes}/{slug}/fields

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 плавающего виджета:

this.dispatchEvent(new CustomEvent('resolve', { detail: { id: 42 } }));

Примечание

Для «создать страницу типа 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

  1. Отдельный контроллер API — classes/ApiController.php обрабатывает все эндпоинты REST
  2. Повторное использование существующих менеджеров — ThemeManager и GrammarManager используются и классической админкой, и контроллером API
  3. Обработка загрузки файлов — откатывается к $_FILES, когда PSR-7 getUploadedFiles() возвращает пустой результат
  4. Модальное окно в document.body — средство выбора темы добавляет своё модальное окно в document.body, чтобы обойти ограничения shadow DOM
  5. Однопроходная токенизированная подсветка — использует одно регулярное выражение с группами чередования для подсветки синтаксиса, чтобы избежать самосовпадений

Пользовательские страницы админки

Помимо пользовательских типов полей, плагины могут регистрировать собственные полные страницы в боковой панели 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:

your-plugin/
  admin/
    blueprints/
      your-page.yaml       # Стандартная YAML-схема Grav

Режим компонента

Установите page_type в 'component' и разместите JavaScript-файл по пути:

your-plugin/
  admin-next/
    pages/
      your-plugin.js       # Веб-компонент полной страницы

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.

Как всё соединяется

  1. Admin2 загружается и вызывает GET /sidebar/items — license-manager добавляет свою запись «Licenses»
  2. Пользователь нажимает элемент боковой панели, Admin2 переходит на /plugin/license-manager
  3. Admin2 вызывает GET /gpm/plugins/license-manager/page — плагин возвращает определение своей страницы
  4. Admin2 видит page_type: 'blueprint', загружает схему из GET /blueprints/plugins/license-manager/pages/licenses
  5. Admin2 загружает текущие данные из GET /licenses/form-data
  6. Форма рендерится со стандартными полями (array для лицензий) и пользовательским полем (веб-компонент products-status)
  7. Кнопка 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' показывает сообщение об ошибке:

$event['result'] = ['status' => 'success', 'message' => 'CDN cache purged.'];

Блокировка сохранения ошибкой

Чтобы остановить сохранение и сообщить пользователю причину, выбросьте исключение из контроллера. 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 вашего плагина:

compatibility:
  grav:
    - 2.0
  api:
    - 1.0

Это сигнализирует экосистеме, что ваш плагин: - Протестирован с плагином 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, чтобы просмотреть историю.