Newer
Older
navi-1 / webclient / src / App.vue
<template>
  <GnToastProvider>
    <div class="app-shell">
      <!-- Mobile sidebar backdrop -->
    <div
      v-if="sidebarOpen"
      class="sidebar-backdrop"
      @click="closeSidebar"
    />

    <Transition name="backdrop-fade">
      <div
        v-if="artifactsOpen"
        class="artifacts-backdrop"
        @click="closeArtifacts"
      />
    </Transition>

    <!-- Sidebar (desktop: always visible; mobile: drawer) -->
    <AppSidebar
      :mobile-open="sidebarOpen"
      :class="{ 'is-hidden-by-artifacts': artifactsOpen }"
      @close="closeSidebar"
    />

    <!-- Global confirm dialog -->
    <ConfirmDialog />

    <!-- Global image lightbox -->
    <ImageLightbox />

    <!-- Selection reply toolbar -->
    <SelectionToolbar />

    <!-- Offline indicator: browser lost the network (shell still opens from SW cache) -->
    <OfflineBanner />

    <!-- Main content -->
    <div class="app-main">
      <!-- Push permission: offered once per browser session, and only to a
           signed-in user. A full-width bar, so it lives in this column rather
           than beside it (see .app-shell — that is a row). -->
      <NotificationPromptBanner
        :open="promptVisible && route !== 'settings'"
        :busy="promptBusy"
        :title="promptTitle"
        :text="promptText"
        :accept-text="promptAcceptText"
        :dismiss-text="promptDismissText"
        @accept="acceptPushPrompt"
        @dismiss="declinePushPrompt"
      />

      <Transition name="fade" mode="out-in">
        <WelcomeScreen
          v-if="showWelcome"
          key="welcome"
          @toggle-sidebar="toggleSidebar"
        />
        <SettingsView
          v-else-if="route === 'settings'"
          key="settings"
          @toggle-sidebar="toggleSidebar"
        />
        <ChatArea
          v-else
          key="chat"
          @toggle-sidebar="toggleSidebar"
          @toggle-artifacts="toggleArtifacts"
        />
      </Transition>
    </div>

    <ArtifactsPanel
      :open="artifactsOpen"
      @close="closeArtifacts"
    />

    <!-- Login overlay: blocks the UI when auth is required but user is not logged in -->
    <LoginScreen
      v-if="authStore.authConfigured && !authStore.isAuthenticated && !authStore.loading"
    />
  </div>
  </GnToastProvider>
</template>


<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useSessionsStore } from '@/stores/sessions'
import { useProfilesStore } from '@/stores/profiles'
import { useChatStore } from '@/stores/chat'
import { useAuthStore } from '@/stores/auth'
import { useNotificationPrompt } from '@/composables/useNotificationPrompt.js'
import { isSettingsHash } from '@/utils/hash.js'
import { setLocale, storedLocale, t } from '@/i18n/index.js'
import AppSidebar from '@/components/sidebar/AppSidebar.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
import ImageLightbox from '@/components/ui/ImageLightbox.vue'
import SelectionToolbar from '@/components/ui/SelectionToolbar.vue'
import ArtifactsPanel from '@/components/artifacts/ArtifactsPanel.vue'
import LoginScreen from '@/components/ui/LoginScreen.vue'
import NotificationPromptBanner from '@/components/ui/NotificationPromptBanner.vue'
import OfflineBanner from '@/components/ui/OfflineBanner.vue'
import WelcomeScreen from '@/components/ui/WelcomeScreen.vue'
import ChatArea from '@/components/chat/ChatArea.vue'
import SettingsView from '@/components/settings/SettingsView.vue'

const sessionsStore = useSessionsStore()
const profilesStore = useProfilesStore()
const chatStore = useChatStore()
const authStore = useAuthStore()

// The offer to turn push notifications on: shown once per browser session,
// from here rather than from the settings screen, whose switch is the only
// way back after an explicit block.
const {
  visible: promptVisible,
  busy: promptBusy,
  title: promptTitle,
  text: promptText,
  acceptText: promptAcceptText,
  dismissText: promptDismissText,
  maybeShow: maybeShowPushPrompt,
  accept: acceptPushPrompt,
  decline: declinePushPrompt,
} = useNotificationPrompt()

const sidebarOpen = ref(false)
const artifactsOpen = ref(false)

function getRouteFromHash() {
  const hash = location.hash
  if (isSettingsHash(hash)) return 'settings'
  return 'chat'
}

const route = ref(getRouteFromHash())

const showWelcome = computed(() => route.value === 'chat' && !chatStore.currentId && !chatStore.loading)

const documentTitle = computed(() => {
  if (route.value === 'settings') return t('app.titleSettings')
  if (!chatStore.currentId) return 'Navi'
  const id = chatStore.currentId
  // nameById persists beyond the loaded list page (the list holds only ~30)
  return (
    sessionsStore.nameById[id] ||
    sessionsStore.sessions.find(s => s.session_id === id)?.name ||
    id.slice(0, 8)
  )
})

watch(documentTitle, (title) => {
  document.title = title
}, { immediate: true })

function closeSidebar() {
  sidebarOpen.value = false
}

function closeArtifacts() {
  artifactsOpen.value = false
}

function toggleSidebar() {
  const next = !sidebarOpen.value
  if (next) artifactsOpen.value = false
  sidebarOpen.value = next
}

function toggleArtifacts() {
  const next = !artifactsOpen.value
  if (next) sidebarOpen.value = false
  artifactsOpen.value = next
}

function handleHashChange() {
  route.value = getRouteFromHash()
  const hash = location.hash
  if (isSettingsHash(hash)) return
  const id = hash.slice(1)
  if (id && id !== chatStore.currentId) {
    chatStore.loadSession(id)
  }
}

onMounted(async () => {
  // Hash-based routing
  window.addEventListener('hashchange', handleHashChange)

  // Check auth configuration first, before any protected API calls
  await authStore.fetchStatus()

  // Resolve auth state only when auth is configured on the backend.
  // In disabled-auth mode we skip /auth/me to avoid an unnecessary 401.
  if (authStore.authConfigured) {
    try {
      await authStore.fetchMe()
    } catch {
      // unauthenticated — LoginScreen will show if auth is configured
    }
  }

  // Interface language: the account's own, with whatever was chosen in
  // Settings, as soon as there is an account — this browser's choice otherwise.
  setLocale(authStore.user ? authStore.user.locale_effective : storedLocale())

  // Load app data only if authenticated or auth is not configured
  if (!authStore.authConfigured || authStore.isAuthenticated) {
    try {
      await profilesStore.fetchProfiles()
      await sessionsStore.fetchSessions(profilesStore.selectedProfileId)
    } catch {
      // ignore
    }
  }

  // Load session from initial hash after auth is resolved
  if (location.hash && !isSettingsHash(location.hash)) {
    const id = location.hash.slice(1)
    if (id) chatStore.loadSession(id)
  }

  // Offer push notifications once the app is actually usable. The SSO return
  // is a full reload, so this is the whole "after login" hook — no watcher.
  if (!authStore.authConfigured || authStore.isAuthenticated) {
    await maybeShowPushPrompt()
  }
})

onUnmounted(() => {
  window.removeEventListener('hashchange', handleHashChange)
})

</script>

<style scoped>
.app-shell {
  display: flex;
  flex: 1 1 0;
  min-height: 0;
  min-width: 0;
  overflow: hidden;
}

.sidebar-backdrop {
  display: none;
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  z-index: 99;

  @media (max-width: 1279.98px) {
    display: block;
  }
}

.artifacts-backdrop {
  display: none;
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.45);
  z-index: 119;

  @media (max-width: 980px) {
    display: block;
  }
}

.backdrop-fade-enter-active,
.backdrop-fade-leave-active {
  transition: opacity 0.18s ease;
}

.backdrop-fade-enter-from,
.backdrop-fade-leave-to {
  opacity: 0;
}
</style>