Skip to content

UI темы

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

Всё здесь идёт в ветку else (тема) функции onLoad() в main.js. Если вы ещё не настроили main.js, сначала прочитайте Разработку фронтенда.

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

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

js
pano.ui.hook.register({
  name: 'page:home:top',
  component: viewComponent(() => import('./theme/ShoutboxWidget.svelte')),
});

Проверка

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

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

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

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

svelte
<!-- src/theme/ShoutboxWidget.svelte -->
<script module>
  import ApiUtil from '@panomc/sdk/utils/api';

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

<script>
  export let shouts = [];
</script>

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

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

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

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

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

Проверка

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

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

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

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

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

js
import ApiUtil from '@panomc/sdk/utils/api';

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

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

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

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

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

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

ApiUtil никогда не бросает исключение на ошибках API — проваленный вызов разрешается в объект с установленным error (он не бросает, и вы не проверяете HTTP-статус). Всегда проверяйте res.error перед использованием ответа; вы увидите это в каждом примере.

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

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

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

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

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

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

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

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

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

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

js
const registeredPaths = new Set();
const customPageComponent = viewComponent(() => import('./theme/CustomPage.svelte'));

pano.ui.app.onLoad(async (data, event) => {
  const res = await ApiUtil.get({ path: '/api/pages', request: event });
  const incoming = new Set(res.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 res.pages) {
    pano.ui.page.register({ path: page.url, component: customPageComponent });
    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() и ApiUtil.