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 { GnNavigationShell, GnUserCard } 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;

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

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>
      <GnUserCard
        v-if="me"
        compact
        :name="me.email"
        :avatar="{ initials }"
        :actions="[{ label: 'Выйти', icon: 'ph-sign-out', onClick: signOut }]"
      />
    </template>
    <template #content>
      <RouterView />
    </template>
  </GnNavigationShell>
  <RouterView v-else />
</template>