Skip to content

UI темы ​

Что даёт вам эта страница: всё для обращённой к посетителю стороны вашего аддона — смонтируйте компонент в тему, дайте ему серверно-отрисованные данные и вызовите свой бэкенд. К концу виджет Shoutbox показывает недавние выкрики на главной странице.

Сторона сайта состоит из файлов представлений в src/theme/views/ (см. раздел о представлениях в Разработке фронтенда); кода в main.js для них не нужно. В ветку else (тема) функции onLoad() в main.js идут только регистрации из части «Продвинутое» ниже.

Смонтируйте виджет на главной странице ​

Тема предоставляет именованные хуки — места, куда аддоны могут внедрить компонент (полный список имён хуков — в Справочнике API фронтенда; это руководство использует page:home:top). Чтобы показать Shoutbox на главной странице, укажите хук page:home:top в строке export const view файла представления:

svelte
<!-- src/theme/views/ShoutboxWidget.svelte -->
<script module>
  export const view = { hook: 'page:home:top' };
</script>

<div class="shoutbox"></div>

Это вся регистрация. На стороне сайта pano.ui.hook.register({ name, component }) не работает: элемент с component и без view отбрасывается с ошибкой.

Проверка

При запущенных dev-серверах откройте главную страницу сайта. Вы должны увидеть контейнер <div class="shoutbox"> в самом верху страницы (осмотрите его через devtools браузера). Если вы ещё не добавили load() — следующий шаг — он будет пустым; так и должно быть. Если вы его вообще не видите, проверьте консоль браузера на ошибки и убедитесь, что файл лежит в src/theme/views/ и имя хука написано верно.

Дайте виджету серверно-отрисованные данные через load() ​

Хорошему виджету нужны данные, и нужно, чтобы эти данные были там в первом ответе сервера — а не подгружались позже в браузере — чтобы посетители и поисковые системы видели выкрики сразу. Pano делает это через SSR (серверный рендеринг — сервер строит готовый HTML перед отправкой, вместо того чтобы отправить пустую страницу, которая подгружает свои данные потом).

Компонент хука может экспортировать функцию load(event) из своего модульного скрипта — блока <script module>. Этот блок выполняется один раз, когда файл впервые загружается, до того как существует хоть один экземпляр компонента, — вот почему load() живёт там, а ваш обычный код на экземпляр компонента — в обычном <script> под ним. Тема выполняет load(), пока страница готовится (на сервере во время SSR и снова на клиенте при навигации между страницами), и передаёт всё, что вы вернёте, компоненту как props. В Shoutbox load() вызывает наш публичный эндпоинт:

svelte
<!-- src/theme/views/ShoutboxWidget.svelte -->
<script module>
  import { api } from '@panomc/sdk/plugin-api';

  export const view = { hook: 'page:home:top' };

  export async function load(event) {
    const res = await api.get({ path: '/shouts', request: event });
    return { shouts: res.items ?? [] };
  }
</script>

<script>
  let { shouts = [] } = $props();
</script>

<div class="shoutbox">
  {#each shouts as shout}
    <p class="shout">{shout.message}</p>
  {/each}
</div>

event — это входящий запрос страницы — он несёт куки и сессию посетителя. В основном вы просто перенаправляете его в api (как request: event), чтобы API знал, кто спрашивает.

Объект, который вы возвращаете, становится props компонента — здесь shouts приходит готовым к отрисовке. (Хост называет этот поток props hookProps; вы встретите это имя в справочнике API и в сообщениях об ошибках.)

load() выполняется на сервере и на клиенте

Один и тот же load() выполняется во время SSR и снова при клиентской навигации, поэтому держите его безопасным для двойного запуска: он должен только получать и возвращать данные. Не меняйте глобальные переменные, ничего не записывайте и не изменяйте объекты, которые вам передали, — потому что одна и та же функция выполняется один раз на сервере и снова в браузере. (Слово из одного термина для «безопасно запускать дважды без побочных эффектов» — идемпотентный.) Всегда передавайте request: event в api (следующий раздел), чтобы серверный вызов нёс сессию посетителя.

Проверка

Обновите главную страницу. <div class="shoutbox"> теперь должен содержать по одному <p class="shout"> на выкрик (при условии, что у вашего бэкенда они есть). Чтобы подтвердить, что данные действительно в первом ответе, сделайте жёсткое обновление (Ctrl/Cmd+Shift+R) и используйте «Просмотр исходного кода» — вы должны увидеть выкрики уже присутствующими в HTML, а не пустоту.

Скрывайте виджет, когда ему нечего показать

Если load() возвращает { hookOptions: { invisible: true } }, хост ничего не отрисовывает для этого хука. Аддон Announcement использует это, чтобы исчезать, когда нечего отображать.

Вызов вашего API ​

Вызовы эндпоинтов вашего собственного плагина идут через api — клиент, который уже знает id вашего плагина. Пути — относительные, те, что объявил ваш эндпоинт Kotlin (/shouts), а api.panel обращается к эндпоинтам PanelApi. Вызовы эндпоинтов ядра используют ApiUtil с путями относительно /api/v1. Каждый метод принимает один объект опций:

js
import { api } from '@panomc/sdk/plugin-api';
import ApiUtil from '@panomc/sdk/utils/api';   // core endpoints only

// In a load() — pass request so the server-side call has the session:
const res = await api.get({ path: '/shouts', request: event });

// In a browser event handler — body is your JSON payload:
await api.panel.post({ path: '/shouts', body: { message } });
await api.panel.delete({ path: `/shouts/${id}` });
await api.panel.put({ path: '/config', body: config });

Правило: внутри load() всегда передавайте request: event, чтобы запрос выполнялся с сессией посетителя во время SSR. В обработчике клика, выполняющемся в браузере, вы можете его опустить.

Если вы забудете request: event

Вызов всё равно работает в браузере, но во время SSR он выполняется вышедшим из системы. Симптом сбивает с толку: данные отсутствуют или вы получаете ошибки прав доступа только при жёстком обновлении, тогда как при кликах по сайту всё выглядит нормально. Если вы когда-нибудь это увидите, сначала проверьте свои вызовы load().

Как ApiUtil сообщает об ошибках

api и ApiUtil никогда не бросают исключение на ошибках API: проваленный вызов разрешается в { error: { code, message?, details?, fields? } }. Всегда проверяйте res.error (и ветвитесь по res.error.code) перед использованием ответа. Списки приходят как { items, page }; см. Страницы.

Продвинутое — пропустите при первом чтении ​

Всё ниже — для случаев, с которыми вы не столкнётесь на своём первом аддоне. Вернитесь, когда понадобится.

Получите общие данные один раз через pano.ui.app.onLoad ​

pano.ui.app.onLoad(callback) регистрирует функцию, которую тема выполняет при каждом запросе страницы, до её отрисовки. Её колбэк получает (data, event), где data — общий мешок данных страницы, а event — запрос (того же вида, что вы передаёте в api). Используйте его, когда один запрос должен питать несколько регистраций сразу.

Это альтернатива load() на каждый компонент: дайте представлению хука skipLoad: true и получайте его данные из единственного pano.ui.app.onLoad(async (data, event) => { ... }). Так делает аддон Announcement; аддоны FAQ и Pages используют app.onLoad, чтобы регистрировать страницы из полученных данных.

Динамические страницы и очистка ​

Иногда страницы, которые вы регистрируете, неизвестны на этапе сборки — они приходят из вашего бэкенда (пользовательские URL, редиректы и подобное). Регистрируйте их изнутри pano.ui.app.onLoad, после получения списка. Аддон Pages делает именно это.

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

Вот здесь острый угол. pano.ui.app.onLoad выполняется при каждом запросе, но маршруты и ссылки, которые вы регистрируете, сохраняются в этом процессе между запросами. Если страница удалена в панели, запись, которую вы зарегистрировали ранее, задерживается — SSR всё ещё обслуживает призрачный маршрут, пока процесс не перезапустится, даже если браузер о нём больше не знает.

Исправление — отслеживать то, что вы зарегистрировали, и удалять записи, которых больше нет, через pano.ui.page.unregister(path):

js
const registeredPaths = new Set();

pano.ui.app.onLoad(async (data, event) => {
  const res = await api.get({ path: '/pages', request: event });
  const pages = res.items ?? [];
  const incoming = new Set(pages.map((p) => p.url));

  // Remove routes we registered before that are no longer present.
  for (const path of registeredPaths) {
    if (!incoming.has(path)) {
      pano.ui.page.unregister(path);
      registeredPaths.delete(path);
    }
  }

  for (const page of pages) {
    // the view file is src/theme/views/CustomPage.svelte; on the site a page names a view, not a component
    pano.ui.page.register({ path: page.url, view: 'shoutbox:CustomPage' });
    registeredPaths.add(page.url);
  }
});

Повторная регистрация страницы через pano.ui.page.register безопасна: тот же путь просто перезаписывает предыдущую запись, поэтому защита от дубликатов здесь не нужна — в отличие от ссылок навигации, которые дублировались бы, из-за чего editNavLinks нужна его проверка some(...).

Призрачные маршруты в SSR

Динамические страницы, зарегистрированные из данных, выживают в процессе Node (SSR — серверный рендеринг — выполняется в одной долгоживущей программе). Если вы никогда не снимаете с регистрации удалённые элементы, удалённые страницы продолжают обслуживаться во время SSR, пока Pano не перезапустится. Аддон pano-plugin-link-redirects — полный образец для этого паттерна очистки, включая удаление устаревших ссылок навигации, которые вы добавили.

Что дальше ​

Сторона посетителя готова: виджет на главной странице с SSR-данными, вызовы API и (опционально) динамические страницы.

  • Добавьте административные экраны → UI панели — разделы настроек, полноценные страницы панели со ссылками навигации и toast-ы.
  • Справочник API фронтенда — каждое имя хука, слот представления и событие жизненного цикла в одном месте.
  • Перевод текста — помощник $_, который ваши компоненты используют для ярлыков.
  • Разработка бэкенда — эндпоинты Kotlin, в которые попадают ваши вызовы load() и api.