Newer
Older
gn-synapse / frontend / src / App.vue
<script setup>
// Каркас приложения — GnNavigationShell из gnexus-ui-kit: topbar + drawer + footer.
// Страницы входа/отсечения идут без каркаса (meta.bare в роутере).
//
// Футер дровер — карточка профиля (GnUserCard) с кнопкой выхода; здесь же живёт
// сессия: /api/v1/admin/me каждые 45 c (single sign-out: вебхук отзывает токены,
// heartbeat ловит 401 и уводит на /login).
//
// :key="route.path" — обход ограничения vue-адаптера кита: GnNavList читает
// route.path ($route) один раз при первом рендере, а vue-router отдаёт $route
// как unref(currentRoute) — без реактивности, computed с isActive закешируется
// навсегда. Перезмонтирование шела на каждую смену пути даёт корректную
// подсветку активного пункта навигации.
import { computed, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { GnIdentity, GnIconButton, GnNavigationShell } from "gnexus-ui-kit/vue";
import { adminMe, clearToken, logout } from "./api.js";

const route = useRoute();
const router = useRouter();

// Пока в Synapse одна секция — «Обзор»; новые секции добавляются сюда и в router.js.
const items = [
  { to: "/dashboard", label: "Обзор", icon: "ph-pulse" },
];
const currents = {
  "/dashboard": "Обзор",
};
const current = computed(() => currents[route.path] || "");
const bare = computed(() => Boolean(route.meta.bare));

const me = ref(null);
let heartbeatTimer = null;

// Имя для карточки: display_name -> username -> email (userinfo profile scope).
const displayName = computed(
  () => me.value?.display_name || me.value?.username || me.value?.email || "",
);

const initials = computed(() => {
  const src = displayName.value || me.value?.email || "";
  return src
    .split(/[.\s@_]+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((w) => w[0].toUpperCase())
    .join("");
});

// Аватар identity: картинка из gnexus-auth или initials (GnAvatar рендерит
// их строкой сам). size sm + статус online — как в демо каркаса кита.
const identityAvatar = computed(() => ({
  size: "sm",
  status: "online",
  ...(me.value?.avatar_url
    ? { src: me.value.avatar_url, alt: displayName.value }
    : { initials: initials.value }),
}));

function stopHeartbeat() {
  if (heartbeatTimer) {
    clearInterval(heartbeatTimer);
    heartbeatTimer = null;
  }
}

function authFailure(ex) {
  stopHeartbeat();
  if (ex?.status === 403) router.push("/denied");
  else if (ex?.status === 401) {
    clearToken();
    router.push("/login");
  }
}

async function loadProfile() {
  try {
    me.value = await adminMe();
  } catch (ex) {
    authFailure(ex);
  }
}

function startHeartbeat() {
  stopHeartbeat();
  heartbeatTimer = setInterval(async () => {
    try {
      me.value = await adminMe();
    } catch (ex) {
      authFailure(ex);
    }
  }, 45000);
}

watch(
  bare,
  (isBare) => {
    if (isBare) {
      me.value = null;
      stopHeartbeat();
    } else {
      loadProfile();
      startHeartbeat();
    }
  },
  { immediate: true },
);

onUnmounted(stopHeartbeat);

async function signOut() {
  stopHeartbeat();
  me.value = null;
  await logout();
  router.push("/login");
}
</script>

<template>
  <GnNavigationShell
    v-if="!bare"
    :key="route.path"
    :items="items"
    brand="Synapse"
    title="Synapse"
    subtitle="Хаб уведомлений Gnexus"
    :current="current"
  >
    <template #footer>
      <!-- Паттерн «Navigation Shell Profile Footer» из доков кита:
           ссылка-identity + кнопки-иконки. -->
      <a
        v-if="me"
        class="profile-identity"
        :href="me.account_url"
        target="_blank"
        rel="noopener noreferrer"
      >
        <GnIdentity
          :title="displayName"
          :meta="me.email || ''"
          :avatar="identityAvatar"
        />
      </a>
      <GnIdentity
        v-else-if="!bare"
        title="Загрузка…"
        :avatar="{ size: 'sm' }"
      />
      <GnIconButton icon="ph-sign-out" label="Выйти" @click="signOut" />
    </template>
    <template #content>
      <RouterView />
    </template>
  </GnNavigationShell>
  <RouterView v-else />
</template>