Newer
Older
gnexus-tasks / frontend / src / App.vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { api } from './api'
import { resolveLocale } from './i18n/locale'
import { setCurrency } from './taskui'
import GamifyBridge from './components/GamifyBridge.vue'
import type { UserInfo } from './types'

const route = useRoute()
const { t } = useI18n()
const user = ref<UserInfo | null>(null)
const accountUrl = ref('')
const navItems = computed(() => [
  { id: 'home', label: t('nav.home'), icon: 'ph-house', to: '/' },
  { id: 'garden', label: t('nav.garden'), icon: 'ph-leaf', to: '/garden' },
  { id: 'stats', label: t('nav.stats'), icon: 'ph-chart-bar', to: '/stats' },
  { id: 'list', label: t('nav.list'), icon: 'ph-list-bullets', to: '/list' },
  { id: 'projects', label: t('nav.projects'), icon: 'ph-folders', to: '/projects' },
  { id: 'options', label: t('nav.options'), icon: 'ph-shuffle', to: '/options' },
  { id: 'settings', label: t('nav.settings'), icon: 'ph-gear-six', to: '/settings' },
])

const currentLabel = computed(() => {
  const item = navItems.value.find((i) => i.to === route.path)
  if (item) return item.label
  // глубокие страницы: задача → страница задачи, проект → раздел проектов
  if (route.path.startsWith('/tasks/')) return t('nav.task')
  if (route.path.startsWith('/projects/')) return t('nav.projects')
  return 'GNexus Tasks'
})

onMounted(async () => {
  const res = await fetch('/auth/me')
  if (res.ok) {
    const data = await res.json()
    user.value = data.user
    accountUrl.value = data.account_url ?? ''
  }
  // валюта и язык — глобальные настройки; язык без переопределения берётся из SSO
  try {
    const s = await api.getSettings()
    setCurrency(s.currency)
    resolveLocale(s.language, user.value?.locale)
  } catch {
    setCurrency('UAH')
    resolveLocale(undefined, user.value?.locale)
  }
})
</script>

<template>
  <GnToastProvider>
    <GnNavigationShell
      brand="GNexus Tasks"
      logo-src="/logo.svg"
      :items="navItems"
      :current="currentLabel"
      :title="t('nav.sections')"
      :subtitle="t('nav.subtitle')"
    >
      <!-- Футер навигации: профиль + выход (паттерн profile-identity из кита,
           как в gnexus auth); чип топбара показывает текущий раздел -->
      <!-- Список навигации рендерим сами (публичный GnNavList): внутренний список
           кита захватывает $route один раз в setup и после навигации «устаёт» —
           активным навсегда остаётся «Главная». :key перемонтирует список при
           каждом переходе, переснимая актуальный маршрут. close — из scope слота. -->
      <template #default="{ close }">
        <GnNavList :key="route.fullPath" :items="navItems" @select="close" />
      </template>
      <template #footer>
        <template v-if="user">
          <!-- Профиль — в системе авторизации (gnexus auth, раздел Account) -->
          <a class="profile-identity" :href="accountUrl" target="_blank" rel="noopener noreferrer">
            <span class="identity">
              <span class="avatar avatar-sm">
                <img v-if="user.avatar_url" :src="user.avatar_url" alt="" />
                <i v-else class="ph ph-user" aria-hidden="true"></i>
              </span>
              <span class="identity-content">
                <span class="identity-title">{{ user.email }}</span>
                <span class="identity-meta">{{ t('nav.account') }}</span>
              </span>
            </span>
          </a>
          <a class="btn-icon" href="/auth/logout" :aria-label="t('nav.signOut')">
            <i class="ph ph-sign-out" aria-hidden="true"></i>
          </a>
        </template>
      </template>
      <template #content>
        <GamifyBridge />
        <div class="app-content">
          <RouterView />
        </div>
      </template>
    </GnNavigationShell>
  </GnToastProvider>
</template>

<style>
.app-content {
  max-width: 1200px;
  margin: 0 auto;
  padding: 1.5rem 1rem 3rem;
}
/* У кита .card имеет intrinsic max-width: 340px / width: max-content — в наших видах
   карточки должны заполнять колонку контента целиком (иначе обрезаются действия).
   overflow отключаем, иначе dropdown-меню внутри карточки обрезается. */
.app-content .card {
  max-width: 100%;
  width: 100%;
  overflow: visible;
}
/* знак в шапке крупнее дефолтных 22px кита */
.nav-topbar-brand img {
  width: 48px;
  height: 48px;
}
/* отделение заголовка страницы от контента (у кита margin нет) */
.app-content .page-header {
  margin-bottom: 1.5rem;
}
/* drawer детализации шире дефолтных 460px кита (на мобильных ограничен вьюпортом).
   Специфичность должна перебить .drawer .drawer-panel кита — потому тот же селектор. */
.drawer .drawer-panel {
  width: min(640px, 100vw - 18px);
}
/* дропдауны из шапки страницы не должны обрезаться (overflow:hidden у .page-header кита);
   декоративную скан-полоску ::after убираем — с overflow:visible она вылезает за рамку */
.app-content .page-header {
  overflow: visible;
  position: relative;
  /* transform из panel_boot кита создаёт stacking context — без z-index
     идущие позже карточки рисуются поверх выпадающего меню */
  z-index: 5;
}
.app-content .page-header::after {
  display: none;
}
/* меню в шапке прижимается к правому краю кнопки (left:0 кита вылетает за вьюпорт) */
.page-header .dropdown.is-open .dropdown-menu {
  left: auto;
  right: 0;
}
/* иконки в заголовках карточек — тёмные, не выпячиваются */
.app-content .card .card-title-icon {
  color: #3b4261;
}
/* иконка внутри бейджа (GnBadge не имеет пропа icon — кладём <i> в слот) */
.badge .ph {
  margin-right: 0.35em;
}
/* конфетти за закрытую задачу (gamification.ts рисует слой частиц) */
.confetti-layer {
  position: fixed;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  z-index: 9999;
}
.confetti-piece {
  position: absolute;
  top: -16px;
  width: 9px;
  height: 14px;
  border-radius: 2px;
  animation-name: confetti-fall;
  animation-timing-function: ease-in;
  animation-fill-mode: forwards;
}
@keyframes confetti-fall {
  0% {
    transform: translate(0, 0) rotate(0deg);
    opacity: 1;
  }
  100% {
    transform: translate(var(--drift, 0), 105vh) rotate(var(--rot, 360deg));
    opacity: 0.6;
  }
}
</style>