<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>