<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import {
GnAvatar, GnButton, GnIconButton, GnIdentity, GnLoader,
GnNavList, GnNavigationShell, GnToastProvider,
} from 'gnexus-ui-kit/vue'
import { api } from './api.js'
import { setLocale, t } from './i18n/index.js'
import { router } from './router.js'
// GnNavList захватывает $route одним снапшотом в setup, и с vue-router 4
// класс active «застывает» (грабля hard-panel). Активность считаем сами через
// реактивный useRoute и отдаём флагом item.active; переходы — наш onSelect.
const route = useRoute()
const navItems = computed(() => [
{
label: t('nav.accounts'), icon: 'ph-chat-circle-dots', href: '/accounts',
active: route.path.startsWith('/accounts'),
onSelect: (item, event) => { event.preventDefault(); router.push(item.href) },
},
{
label: t('nav.mcpKeys'), icon: 'ph-key', href: '/keys',
active: route.path.startsWith('/keys'),
onSelect: (item, event) => { event.preventDefault(); router.push(item.href) },
},
{
label: t('nav.settings'), icon: 'ph-gear', href: '/settings',
active: route.path.startsWith('/settings'),
onSelect: (item, event) => { event.preventDefault(); router.push(item.href) },
},
{
// видна только админам; AdminPage ещё раз проверяет роль из /me
label: t('nav.admin'), icon: 'ph-users', href: '/admin',
active: route.path.startsWith('/admin'),
onSelect: (item, event) => { event.preventDefault(); router.push(item.href) },
},
])
// Язык, пока SPA открыт (SettingsPage меняет его через /me или localStorage).
function bootLocale(data) {
if (data.auth_enabled && data.user) {
// язык аккаунта с перекрытием из настроек — бэкенд считает сам
setLocale(data.locale_effective)
return
}
// открытый режим / не вошли — выбор хранится в этом браузере
setLocale(localStorage.getItem('tgclient.locale'))
}
// --- Шлюз входа (как в hard-panel): splash → /me → сервис или экран входа.
// OAuth стартует по клику — без тихого авто-редиректа на page load.
const authState = ref('checking') // checking | denied | ready
const me = ref(null)
const userInitials = ref('')
const isAdmin = computed(() =>
['admin', 'superadmin'].includes(me.value?.role))
function initials(name, email) {
const base = (name || email || '?').trim()
return base.slice(0, 2).toUpperCase()
}
async function loadMe() {
authState.value = 'checking'
try {
const data = await api.me()
bootLocale(data)
me.value = data.user
// auth-off (auth_enabled false) — сервис открыт, всё открываем
authState.value = 'ready'
} catch {
// 401 (нет сессии) или сеть — экран входа; /auth/login вернёт обратно
setLocale(localStorage.getItem('tgclient.locale'))
authState.value = 'denied'
}
}
function loginNow() {
// клик начинает OAuth; return_to — текущий маршрут, чтобы вернуться сюда
const to = window.location.pathname + window.location.search
window.location.href = '/auth/login?return_to=' + encodeURIComponent(to)
}
async function logoutNow() {
await api.logout()
me.value = null
authState.value = 'denied'
}
const onUnauthenticated = () => {
me.value = null
authState.value = 'denied'
}
onMounted(() => {
loadMe()
window.addEventListener('tgclient:unauthenticated', onUnauthenticated)
})
onBeforeUnmount(() => {
window.removeEventListener('tgclient:unauthenticated', onUnauthenticated)
})
</script>
<template>
<GnToastProvider :lifetime="4000">
<!-- splash, пока /me не ответил: лого + спиннер -->
<div v-if="authState === 'checking'" class="login-screen" aria-hidden="true">
<article class="card login-screen-card">
<img src="/logo.svg" alt="" class="login-screen-logo" />
<GnLoader circle class="login-screen-loader" />
</article>
</div>
<!-- экран входа: OAuth начинается с клика -->
<div v-else-if="authState === 'denied'" class="login-screen">
<article class="card login-screen-card">
<img src="/logo.svg" alt="" class="login-screen-logo" />
<h1 class="login-screen-title">{{ t('login.title') }}</h1>
<p class="login-screen-subtitle">{{ t('login.subtitle') }}</p>
<GnButton variant="primary" icon="ph ph-sign-in" @click="loginNow">{{ t('login.action') }}</GnButton>
</article>
</div>
<GnNavigationShell
v-else
brand="tgclient"
logo-src="/logo.svg"
current="tgclient"
:title="t('shell.title')"
:subtitle="t('shell.subtitle')"
>
<template #default="{ close }">
<GnNavList
:items="isAdmin ? navItems : navItems.filter((item) => item.href !== '/admin')"
@select="close"
/>
</template>
<template #footer>
<span v-if="me" class="profile-identity">
<GnIdentity
:title="me.display_name || me.email"
:meta="me.email"
:avatar="{ src: me.avatar_url || '', initials: userInitials, size: 'sm', variant: 'primary' }"
/>
</span>
<GnIconButton
v-if="me"
icon="ph ph-sign-out"
:label="t('nav.signOut')"
@click="logoutNow"
/>
</template>
<template #content>
<div class="app-page">
<router-view />
</div>
</template>
</GnNavigationShell>
</GnToastProvider>
</template>
<style>
/* Шлюз входа: полноэкранная карточка с одной кнопкой (стили hard-panel) */
.login-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-screen-card {
width: 100%;
max-width: 380px;
padding: 40px 32px;
text-align: center;
min-width: 0;
}
.login-screen-logo {
width: 56px;
height: 56px;
margin: 0 auto 18px;
display: block;
}
.login-screen-title {
margin-bottom: 6px;
}
.login-screen-subtitle {
margin-bottom: 22px;
}
.login-screen-loader {
margin: 0 auto;
justify-content: center;
}
/* identity текущего пользователя в футере дровера */
.profile-identity {
display: block;
color: inherit;
min-width: 0;
}
/* Служебный layout-контейнер страницы (стили кита не дублируем) */
.app-page {
max-width: 1200px;
margin: 0 auto;
padding: 24px 24px 64px;
width: 100%;
box-sizing: border-box;
}
/* .tabs у кита — flex-wrap: панели занимают собственную строку */
.tabs .tabs-panels {
flex-basis: 100%;
}
</style>