<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { api, type Task } from './api'
import { resolveLocale } from './i18n/locale'
import { startRealtime } from './realtime'
import { setStorageUserId } from './userScope'
import { setCurrency } from './taskui'
import GamifyBridge from './components/GamifyBridge.vue'
import TaskForm from './components/TaskForm.vue'
import type { UserInfo } from './types'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const user = ref<UserInfo | null>(null)
const accountUrl = ref('')
// Страница расширения — только из веб-версии: в установленном PWA пункт скрыт
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
const navItems = computed(() => [
{ id: 'home', label: t('nav.home'), icon: 'ph-house', to: '/' },
{ id: 'options', label: t('nav.options'), icon: 'ph-shuffle', to: '/options' },
{ id: 'list', label: t('nav.list'), icon: 'ph-list-bullets', to: '/list' },
{ id: 'projects', label: t('nav.projects'), icon: 'ph-folders', to: '/projects' },
{ id: 'garden', label: t('nav.garden'), icon: 'ph-leaf', to: '/garden' },
{ id: 'stats', label: t('nav.stats'), icon: 'ph-chart-bar', to: '/stats' },
// архив — редко используемый раздел, предпоследний пункт
{ id: 'archive', label: t('nav.archive'), icon: 'ph-archive', to: '/archive' },
{ id: 'settings', label: t('nav.settings'), icon: 'ph-gear-six', to: '/settings' },
// страница-инструкция расширения — из веб-версии (не в PWA, ТЗ 3.18)
...(isStandalone
? []
: [{ id: 'extension', label: t('nav.extension'), icon: 'ph-puzzle-piece', to: '/extension' }]),
])
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')
if (route.path.startsWith('/archive')) return t('nav.archive')
return 'GNexus Tasks'
})
// Быстрое создание задачи с любой страницы: «+» в топбаре открывает форму,
// после создания ведём на страницу задачи (как из стека)
const createOpen = ref(false)
// Проект по умолчанию из контекста страницы: страница проекта → он,
// страница задачи → её проект
const contextProjectId = ref<number | undefined>(undefined)
watch(createOpen, async (open) => {
if (!open) return
contextProjectId.value = undefined
if (route.path.startsWith('/projects/')) {
contextProjectId.value = Number(route.params.id)
} else if (route.path.startsWith('/tasks/')) {
try {
const task = await api.getTask(Number(route.params.id))
contextProjectId.value = task.project?.id ?? undefined
} catch {
// нет контекста — создаём без проекта
}
}
})
function onCreated(task: Task) {
// форму не закрываем — массовое добавление; страница уходит к созданной
void router.push(`/tasks/${task.id}`)
}
onMounted(async () => {
// оффлайн-старт PWA (ТЗ 3.15): оболочка открывается без сети — fetch
// реджектится, и надо не уронить onMounted молча, а просто остаться гостем
let me: { user: UserInfo; account_url?: string } | null = null
let unauthorized = false
try {
const res = await fetch('/auth/me')
if (res.ok) {
me = await res.json()
} else if (res.status === 401) {
unauthorized = true
}
} catch {
me = null
}
if (me) {
user.value = me.user
accountUrl.value = me.account_url ?? ''
// localStorage (фильтры, дедуп тостов) — скоуп по пользователю: второй
// аккаунт в том же браузере не видит чужих настроек вида
setStorageUserId(me.user.user_id)
// SSE-события (ТЗ 3.14): только для авторизованных — иначе EventSource
// будет бесконечно ловить 401
startRealtime()
} else if (unauthorized) {
// не залогинен — экран входа (ТЗ 3.17); после логина вернём на этот же адрес
// (на самой /login ничего не делаем — уже там; return_to не заворачиваем).
// Сетевая ошибка (оффлайн) — не редиректим: оффлайн-оболочка без сети.
if (route.path !== '/login') {
void router.replace({ path: '/login', query: { return_to: route.fullPath } })
}
// настройки не запрашиваем: лишний 401 на /login ни к чему
return
}
// валюта и язык — глобальные настройки; язык без переопределения берётся из 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>
<!-- экран входа — вне навигационной оболочки (одна кнопка на пустой странице) -->
<RouterView v-if="route.path === '/login'" />
<GnToastProvider v-else>
<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.name || user.email }}</span>
<span class="identity-meta">{{ user.name ? user.email : 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>
<!-- Топбар: вместо бесполезного чипа текущего раздела — «+» создать
задачу (слот current кита) -->
<template #current>
<GnButton class="new-task-full" variant="primary" size="sm" icon="ph-plus" @click="createOpen = true">
{{ t('stack.newTask') }}
</GnButton>
<!-- на узких экранах — штатная кнопка-иконка кита вместо кнопки с лейблом -->
<GnIconButton
class="new-task-compact"
icon="ph-plus"
:label="t('stack.newTask')"
@click="createOpen = true"
/>
</template>
<template #content>
<GamifyBridge />
<div class="app-content">
<RouterView />
</div>
</template>
</GnNavigationShell>
<!-- Глобальная форма создания задачи (из «+» в топбаре). v-if: контекст
(проект по умолчанию) пересчитывается на каждое открытие -->
<GnDrawer v-model:open="createOpen" :title="t('stack.newTask')" position="right">
<TaskForm
v-if="createOpen"
:key="contextProjectId ?? 'none'"
:project-id="contextProjectId"
@saved="onCreated"
@cancel="createOpen = false"
/>
</GnDrawer>
</GnToastProvider>
</template>
<style>
.app-content {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
}
/* «+» создания задачи в топбаре: контейнер кита стилизован как чип раздела
(min-width 160px, рамка, фон) — снимаем, остаётся только кнопка */
.nav-topbar-current {
min-width: 0;
padding: 0;
border: none;
background: none;
display: flex;
align-items: center;
}
/* на узких экранах «Новая задача» — GnIconButton кита (btn-icon) вместо
кнопки с лейблом: один компонент на каждый режим, переключаем display */
.new-task-compact {
display: none;
}
@media (max-width: 640px) {
.new-task-full {
display: none;
}
.new-task-compact {
display: flex;
}
}
/* У кита .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;
}
/* на широких экранах действия шапки («⋯», кнопки) не переносятся под длинный
заголовок: контент сжимается, действия остаются в строке справа */
@media (min-width: 641px) {
.app-content .page-header {
flex-wrap: nowrap;
}
.app-content .page-header .page-header-content {
flex: 1 1 auto;
min-width: 0;
}
.app-content .page-header .page-header-actions {
flex: 0 0 auto;
}
}
/* 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 кита вылетает за вьюпорт);
общий в пределах экрана фикс — installDropdownClamp (JS) в main.ts */
.page-header .dropdown.is-open .dropdown-menu {
left: auto;
right: 0;
}
/* страховка: меню не шире экрана (длинные пункты переносятся) */
.dropdown-menu {
max-width: calc(100vw - 16px);
}
/* иконки в заголовках карточек — тёмные, не выпячиваются */
.app-content .card .card-title-icon {
color: #3b4261;
}
/* GnTagInput: подпись живёт внутри рамки поля (в отличие от GnInput) — у кита
она прилипает к границе; небольшой отступ делает это незаметным всюду */
.tag-input > .label {
padding: 0.4rem 0.75rem 0;
}
/* иконка внутри бейджа (GnBadge не имеет пропа icon — кладём <i> в слот) */
.badge .ph {
margin-right: 0.35em;
}
/* Тосты: у .toast-progress-bar кита анимация без duration — мгновенно доходит
до scaleX(0) (forwards) и перекрывает inline transform, которым полоской
таймера управляет Vue-адаптер. Отключаем — полоска снова видна.
Селектор повторяет специфичность кита (.toast .toast-progress .toast-progress-bar),
иначе переопределение проигрывает независимо от порядка. */
.toast .toast-progress .toast-progress-bar {
animation: none;
}
/* фон тостов у кита — rgba(...,.18) цвета варианта: сквозь него просвечивает
страница. Возвращаем китовый базовый фон тоста (#1f2335), цвет варианта
остаётся в рамке и иконке; селектор 0,2,0 перебивает вариантные 0,1,0 */
.toast.toast-info,
.toast.toast-success,
.toast.toast-warning,
.toast.toast-danger {
background: #1f2335;
}
/* конфетти за закрытую задачу (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;
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>