Newer
Older
navi-1 / webclient / src / components / sidebar / AppSidebar.vue
<template>
  <aside class="app-sidebar" :class="{ 'is-mobile-open': props.mobileOpen }">
    <div class="sidebar-header">
      <div class="sidebar-logo">
        <img src="/images/logo.svg" alt="Navi" />
        <span>Navi</span>
        <GnIconButton icon="ph ph-x" label="Close" class="sidebar-close-btn" @click="emit('close')" />
      </div>

      <div class="sidebar-controls-row">
        <div class="sidebar-profile-select">
          <select
            :value="profilesStore.selectedProfileId"
            @change="profilesStore.selectedProfileId = $event.target.value"
          >
            <option
              v-for="p in profilesStore.profiles"
              :key="p.id"
              :value="p.id"
            >{{ p.name }}</option>
          </select>
        </div>

        <GnButton variant="primary" icon="ph ph-plus" @click="handleNewSession">
          New Chat
        </GnButton>
      </div>
    </div>

    <div class="sidebar-search" :class="{ 'is-open': searchOpen }">
      <div class="sidebar-search-bar">
        <GnIconButton
          icon="ph ph-magnifying-glass"
          label="Search"
          class="search-toggle-btn"
          :class="{ active: searchOpen || sessionsStore.searchActive }"
          @click="toggleSearch"
        />
        <div v-if="searchOpen" class="search-input-wrap">
          <input
            ref="searchInputEl"
            v-model="searchInput"
            type="text"
            placeholder="Search conversations..."
            class="search-input"
            @keydown.esc="closeSearch"
          />
          <GnIconButton
            v-if="searchInput"
            icon="ph ph-x"
            label="Clear"
            class="search-clear-btn"
            @click="clearSearchInput"
          />
        </div>
      </div>
    </div>

    <div class="sidebar-sessions">
      <div class="sessions-header">
        <div class="sessions-label">
          <template v-if="sessionsStore.searchActive">
            {{ sessionsStore.sessions.length }} result{{ sessionsStore.sessions.length === 1 ? '' : 's' }}
          </template>
          <template v-else>Conversations</template>
        </div>
        <GnIconButton
          icon="ph ph-arrow-clockwise"
          label="Refresh"
          class="sessions-refresh-btn"
          :class="{ 'is-spinning': sessionsStore.loading }"
          @click="sessionsStore.fetchSessions(profilesStore.selectedProfileId)"
        />
      </div>
      <SessionList @select="handleSelect" />
    </div>

    <div class="sidebar-footer">
      <template v-if="authStore.isAuthenticated">
        <a
          class="user-info"
          :href="authStore.user?.profile_url"
          target="_blank"
          rel="noopener noreferrer"
          title="Open profile in gnexus-auth"
        >
          <GnAvatar
            size="sm"
            :src="authStore.user?.avatar_url || ''"
            :alt="authStore.user?.display_name || 'avatar'"
            :initials="(authStore.user?.display_name || authStore.user?.email || '?').charAt(0).toUpperCase()"
          />
          <span class="user-name">{{ authStore.user?.display_name || authStore.user?.email }}</span>
        </a>
        <div class="sidebar-footer-actions">
          <a
            v-if="authStore.isAdmin"
            href="/admin"
            target="_blank"
            rel="noopener noreferrer"
            class="btn-icon without-hover admin-link"
            title="Admin"
          >
            <i class="ph ph-shield-check"></i>
          </a>
          <GnIconButton icon="ph ph-sign-out" label="Logout" without-hover @click="authStore.logout" />
        </div>
      </template>
      <template v-else>
        <GnButton variant="primary" icon="ph ph-sign-in" @click="authStore.login">
          Login
        </GnButton>
      </template>
    </div>
  </aside>
</template>

<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useProfilesStore } from '@/stores/profiles.js'
import { useSessionsStore } from '@/stores/sessions.js'
import { useChatStore } from '@/stores/chat.js'
import { useAuthStore } from '@/stores/auth.js'
import SessionList from './SessionList.vue'

const props = defineProps({
  mobileOpen: Boolean
})
const emit = defineEmits(['close'])

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

const searchOpen = ref(false)
const searchInput = ref('')
const searchInputEl = ref(null)
let searchDebounceTimer = null

// Keep selector in sync with the active session's profile
watch(
  () => chatStore.currentProfileId,
  (id) => { if (id) profilesStore.selectedProfileId = id }
)

// Toggle search UI
function toggleSearch() {
  searchOpen.value = !searchOpen.value
  if (searchOpen.value) {
    // Restore previous query if any
    if (sessionsStore.searchQuery) {
      searchInput.value = sessionsStore.searchQuery
    }
    setTimeout(() => searchInputEl.value?.focus(), 50)
  }
}

function closeSearch() {
  searchOpen.value = false
}

function clearSearchInput() {
  searchInput.value = ''
  sessionsStore.clearSearch()
  sessionsStore.fetchSessions(profilesStore.selectedProfileId)
  searchInputEl.value?.focus()
}

// Debounced search
watch(searchInput, (val) => {
  if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
  if (!val) {
    sessionsStore.clearSearch()
    sessionsStore.fetchSessions(profilesStore.selectedProfileId)
    return
  }
  searchDebounceTimer = setTimeout(() => {
    sessionsStore.setSearch(val)
    sessionsStore.fetchSessions()
  }, 300)
})

// Close search when profile changes (but keep state)
watch(
  () => profilesStore.selectedProfileId,
  () => {
    searchOpen.value = false
  }
)

async function handleNewSession() {
  const session = await sessionsStore.createSession(profilesStore.selectedProfileId)
  await chatStore.loadSession(session.session_id)
  emit('close')
}

async function handleSelect(session) {
  // If selected from search, switch to the session's profile
  if (sessionsStore.searchActive && session.profile_id) {
    profilesStore.selectedProfileId = session.profile_id
  }
  await chatStore.loadSession(session.session_id, session.match_indices?.[0])
  emit('close')
}

// Keyboard shortcut: Ctrl/Cmd + K
function onKeyDown(e) {
  if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
    e.preventDefault()
    toggleSearch()
  }
}

onMounted(() => window.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown))
</script>

<style scoped lang="scss">
@use 'kit-deps' as *;

.sidebar-close-btn {
  display: none;

  @media (max-width: 1280px) {
    display: flex;
    margin-left: auto;
  }
}

.sidebar-search {
  padding: 0 $space-md;
  border-bottom: 1px solid var(--color-border);
  display: none;
}

.sidebar-search.is-open {
  display: block;
  padding-top: $space-sm;
  padding-bottom: $space-sm;
}

.sidebar-search-bar {
  display: flex;
  align-items: center;
  gap: $space-xs;
}

.search-toggle-btn {
  font-size: 14px;
  color: $color-text-dark;
  transition: color 0.15s;
}

.search-toggle-btn.active,
.search-toggle-btn:hover {
  color: $color-text-light;
}

.search-input-wrap {
  flex: 1;
  display: flex;
  align-items: center;
  gap: $space-xs;
  position: relative;
}

.search-input {
  flex: 1;
  min-width: 0;
  background: $surface-panel-muted;
  border: $border-width-base $border-style-base $border-color-muted;
  border-bottom-width: $border-width-accent;
  border-bottom-color: $color-text-light;
  color: $color-text-light;
  font-size: 13px;
  padding: $space-xs $space-sm;
  outline: none;
  font-family: inherit;
  transition: border-color $motion-base $motion-ease;
}

.search-input:focus {
  border-color: $color-secondary;
}

.search-input::placeholder {
  color: $color-text-dark;
}

.search-clear-btn {
  font-size: 12px;
  color: $color-text-dark;
}

.sidebar-footer {
  padding: 12px 16px;
  border-top: 1px solid var(--color-border);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

.user-info {
  display: flex;
  align-items: center;
  gap: 8px;
  text-decoration: none;
  color: inherit;
  flex: 1;
  min-width: 0;
  cursor: pointer;
}

.user-info:hover .user-name {
  color: var(--color-text-primary);
}

.user-name {
  font-size: 13px;
  color: var(--color-text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.sidebar-footer-actions {
  display: flex;
  align-items: center;
  gap: 8px;
}

.admin-link {
  text-decoration: none;
}
</style>