Ctrl K
ComunidadGuías…

frontend/app/app.vue

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<script setup lang="ts">
import type { SavedFilter } from '~/types/api'

useHead({
  meta: [
    { name: 'viewport', content: 'width=device-width, initial-scale=1' }
  ],
  link: [
    { rel: 'icon', href: '/favicon.ico' }
  ],
  htmlAttrs: {
    lang: 'en'
  }
})

const title = 'Attic - Home Inventory'
const description = 'A self-hosted home inventory for your belongings, rooms, warranties, receipts, and collections.'

useSeoMeta({
  title,
  description,
  ogTitle: title,
  ogDescription: description
})

const { isAuthenticated: loggedIn, user, isAdmin, logout, fetchSession, isAuthDisabled } = useAuth()
const { features, loaded: featuresLoaded, error: featureError, load: loadFeatures } = useFeatures()
const { loaded: organizationSettingsLoaded, error: organizationSettingsError, load: loadOrganizationSettings, reset: resetOrganizationSettings } = useOrganizationSettings()
const config = useRuntimeConfig()
const { data: savedSearches, refresh: refreshSavedSearches } = useApi<SavedFilter[]>('/api/saved-filters', {
  immediate: false
})
const pinnedSavedSearches = computed(() => (savedSearches.value || []).filter(search => search.pinned).slice(0, 5))

async function retryFeatureLoad() {
  await loadFeatures()
}

type AppInfo = {
  status: string
  version: string
}

const { data: appInfo, execute: fetchAppInfo } = useApi<AppInfo>('/api/', {
  immediate: false
})
const softwareVersion = computed(() => appInfo.value?.version || 'unknown')
const apiDocsUrl = computed(() => `${config.public.apiBase || ''}/api/docs`)

watch(loggedIn, (isLoggedIn) => {
  if (isLoggedIn) {
    void fetchAppInfo()
    void loadFeatures()
    void loadOrganizationSettings()
    void refreshSavedSearches()
  } else {
    resetOrganizationSettings()
    savedSearches.value = []
  }
}, { immediate: true })

// Fetch session on app load
onMounted(() => {
  fetchSession()
})

const route = useRoute()
const requiresCurrency = computed(() => route.path === '/' || /^\/(?:assets|locations)(?:\/|$)/.test(route.path))
const protectedRoutes: Array<[string, keyof typeof features.value]> = [
  ['/locations', 'locations'],
  ['/collections', 'collections'],
  ['/tags', 'tags'],
  ['/categories', 'categories'],
  ['/attributes', 'categories'],
  ['/conditions', 'conditions'],
  ['/warranties', 'warranties'],
  ['/plugins', 'plugins']
]
const currentRouteEnabled = computed(() => {
  const match = protectedRoutes.find(([prefix]) => route.path.startsWith(prefix))
  return !match || features.value[match[1]]
})

watch([() => route.path, featuresLoaded, features], ([path, loaded]) => {
  if (!loaded || typeof path !== 'string') return
  const match = protectedRoutes.find(([prefix]) => path.startsWith(prefix))
  if (match && !features.value[match[1]]) void navigateTo('/')
}, { immediate: true, deep: true })

const baseNavigation = [
  { label: 'Dashboard', to: '/', icon: 'i-lucide-layout-dashboard' },
  { label: 'All Assets', to: '/assets', icon: 'i-lucide-package' },
  { label: 'Locations', to: '/locations', icon: 'i-lucide-map-pin' },
  { label: 'Collections', to: '/collections', icon: 'i-lucide-library' },
  { label: 'Tags', to: '/tags', icon: 'i-lucide-tags' },
  { label: 'Categories', to: '/categories', icon: 'i-lucide-shapes' }
]

const secondaryNavigation = [
  { label: 'Attributes', to: '/attributes', icon: 'i-lucide-sliders-horizontal' },
  { label: 'Conditions', to: '/conditions', icon: 'i-lucide-activity' },
  { label: 'Warranties', to: '/warranties', icon: 'i-lucide-shield-check' },
  { label: 'Import sources', to: '/plugins', icon: 'i-lucide-puzzle' }
]

const navigation = computed(() => {
  const items = baseNavigation.filter((item) => {
    if (item.to === '/locations') return features.value.locations
    if (item.to === '/collections') return features.value.collections
    if (item.to === '/tags') return features.value.tags
    if (item.to === '/categories') return features.value.categories
    return true
  })
  return items
})

const secondaryNav = computed(() => {
  const items = secondaryNavigation.filter((item) => {
    if (item.to === '/attributes') return features.value.categories
    if (item.to === '/conditions') return features.value.conditions
    if (item.to === '/warranties') return features.value.warranties
    if (item.to === '/plugins') return features.value.plugins
    return true
  })
  if (isAdmin.value) {
    items.push({ label: 'Users', to: '/users', icon: 'i-lucide-users' })
    items.push({ label: 'Settings', to: '/settings', icon: 'i-lucide-settings' })
  }
  return items
})

// Check if a nav item is active
const isActive = (to: string) => {
  if (to === '/') return route.path === '/'
  return route.path.startsWith(to)
}
const isSavedSearchActive = (id: string) => route.path === '/assets' && route.query.saved_filter_id === id

type DropdownMenuItem = {
  label: string
  slot?: string
  disabled?: boolean
  icon?: string
  onSelect?: () => void
}

const userMenuItems = computed(() => {
  const items: DropdownMenuItem[][] = [
    [{
      label: user.value?.email || 'User',
      slot: 'account',
      disabled: true
    }]
  ]

  items.push([{
    label: 'Profile',
    icon: 'i-lucide-user-round',
    onSelect: () => { navigateTo('/profile') }
  }])

  if (!isAuthDisabled.value) {
    items.push([{
      label: 'Sign out',
      icon: 'i-lucide-log-out',
      onSelect: () => logout()
    }])
  }

  return items
})

// Mobile sidebar state
const sidebarOpen = ref(false)
const isAssetForm = computed(() => /^\/assets\/(?:new|[^/]+\/edit)\/?$/.test(route.path))
</script>

<template>
  <UApp class="bg-mist-50 dark:bg-mist-900">
    <!-- Login page without sidebar -->
    <template v-if="!loggedIn">
      <NuxtPage />
    </template>

    <!-- Main app layout with sidebar -->
    <template v-else>
      <div class="h-dvh flex overflow-hidden app-canvas">
        <!-- Sidebar -->
        <aside class="hidden lg:flex w-68 bg-white/92 dark:bg-mist-900/92 backdrop-blur-xl border-r border-mist-200/80 dark:border-mist-800 flex-col flex-shrink-0">
          <!-- Logo -->
          <div class="min-h-0 flex-1 overflow-y-auto p-5">
            <NuxtLink
              to="/"
              class="flex items-center gap-3 px-2 mb-9"
            >
              <div class="bg-gradient-to-br from-attic-500 to-attic-700 rounded-[14px] size-11 flex items-center justify-center text-white shadow-primary ring-1 ring-white/20">
                <UIcon
                  name="i-lucide-archive"
                  class="w-5.5 h-5.5"
                />
              </div>
              <div>
                <h1 class="text-mist-950 dark:text-white text-lg font-extrabold leading-none">
                  Attic
                </h1>
                <p class="text-muted dark:text-mist-400 text-[11px] font-semibold tracking-wide uppercase mt-1">
                  Home inventory
                </p>
              </div>
            </NuxtLink>

            <!-- Navigation -->
            <nav class="space-y-1">
              <p class="px-3 pb-2 text-[10px] font-extrabold uppercase tracking-[0.16em] text-muted">
                Your home
              </p>
              <NuxtLink
                v-for="item in navigation"
                :key="item.to"
                :to="item.to"
                class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all"
                :class="isActive(item.to)
                  ? 'bg-attic-50 text-attic-600 shadow-sm ring-1 ring-attic-100 dark:bg-attic-500/15 dark:text-attic-300 dark:ring-attic-500/20'
                  : 'text-mist-600 dark:text-mist-300 hover:bg-mist-50 dark:hover:bg-mist-800'"
              >
                <UIcon
                  :name="item.icon"
                  class="w-5 h-5"
                />
                <span
                  class="text-sm"
                  :class="isActive(item.to) ? 'font-bold' : 'font-semibold'"
                >
                  {{ item.label }}
                </span>
              </NuxtLink>

              <div
                v-if="pinnedSavedSearches.length"
                class="mt-4 space-y-1 border-l border-mist-200 pl-3 dark:border-mist-700"
                aria-label="Pinned saved searches"
              >
                <p class="px-2 pb-1 text-[10px] font-extrabold uppercase tracking-[0.14em] text-muted">
                  Saved searches
                </p>
                <NuxtLink
                  v-for="search in pinnedSavedSearches"
                  :key="search.id"
                  :to="{ path: '/assets', query: { saved_filter_id: search.id } }"
                  class="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-all"
                  :class="isSavedSearchActive(search.id)
                    ? 'bg-attic-50 text-attic-600 dark:bg-attic-500/15 dark:text-attic-300'
                    : 'text-mist-600 hover:bg-mist-50 dark:text-mist-300 dark:hover:bg-mist-800'"
                  :title="search.name"
                >
                  <UIcon
                    name="i-lucide-pin"
                    class="size-3.5 shrink-0"
                  />
                  <span class="truncate text-xs font-semibold">{{ search.name }}</span>
                </NuxtLink>
              </div>

              <!-- Divider -->
              <div class="pt-5 mt-5 border-t border-mist-100 dark:border-mist-800">
                <p class="px-3 pb-2 text-[10px] font-extrabold uppercase tracking-[0.16em] text-muted">
                  Manage
                </p>
                <NuxtLink
                  v-for="item in secondaryNav"
                  :key="item.to"
                  :to="item.to"
                  class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all"
                  :class="isActive(item.to)
                    ? 'bg-attic-50 text-attic-600 shadow-sm ring-1 ring-attic-100 dark:bg-attic-500/15 dark:text-attic-300 dark:ring-attic-500/20'
                    : 'text-mist-600 dark:text-mist-300 hover:bg-mist-50 dark:hover:bg-mist-800'"
                >
                  <UIcon
                    :name="item.icon"
                    class="w-5 h-5"
                  />
                  <span
                    class="text-sm"
                    :class="isActive(item.to) ? 'font-bold' : 'font-semibold'"
                  >
                    {{ item.label }}
                  </span>
                </NuxtLink>
              </div>
            </nav>
          </div>

          <!-- User section at bottom -->
          <div class="mt-auto p-4 border-t border-mist-100 dark:border-mist-800">
            <div class="flex items-center gap-3 p-2 rounded-2xl bg-mist-50/80 dark:bg-mist-800/70">
              <div class="w-9 h-9 rounded-xl bg-attic-100 dark:bg-attic-500/20 flex items-center justify-center text-attic-600 dark:text-attic-300">
                <UIcon
                  name="i-lucide-user"
                  class="w-4 h-4"
                />
              </div>
              <div class="flex flex-col flex-1 min-w-0">
                <span class="text-sm font-bold text-mist-950 dark:text-white truncate">
                  {{ user?.name || user?.email?.split('@')[0] || 'User' }}
                </span>
                <span class="text-xs text-muted dark:text-gray-400 truncate">
                  {{ user?.email }}
                </span>
              </div>
              <UDropdownMenu :items="userMenuItems">
                <UButton
                  color="neutral"
                  variant="ghost"
                  icon="i-lucide-settings"
                  size="sm"
                  aria-label="Open account menu"
                  title="Open account menu"
                />
                <template #account>
                  <div class="text-left">
                    <p class="font-medium">
                      {{ user?.name || 'User' }}
                    </p>
                    <p class="text-xs text-muted truncate">
                      {{ user?.email }}
                    </p>
                  </div>
                </template>
              </UDropdownMenu>
              <UColorModeButton />
            </div>
          </div>
        </aside>

        <!-- Mobile sidebar overlay -->
        <div
          v-if="sidebarOpen"
          class="fixed inset-0 z-40 bg-black/50 lg:hidden"
          @click="sidebarOpen = false"
        />

        <!-- Mobile sidebar -->
        <aside
          class="fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-mist-900 border-r border-gray-200 dark:border-gray-800 flex flex-col lg:hidden transform transition-transform duration-200"
          :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
        >
          <div class="min-h-0 flex-1 overflow-y-auto p-6">
            <div class="flex items-center justify-between mb-8">
              <NuxtLink
                to="/"
                class="flex items-center gap-3"
                @click="sidebarOpen = false"
              >
                <div class="bg-attic-500 rounded-xl size-10 flex items-center justify-center text-white">
                  <UIcon
                    name="i-lucide-archive"
                    class="w-5 h-5"
                  />
                </div>
                <div>
                  <h1 class="text-mist-950 dark:text-white text-lg font-extrabold leading-none">
                    Attic
                  </h1>
                  <p class="text-muted dark:text-gray-400 text-xs font-medium">
                    Home inventory
                  </p>
                </div>
              </NuxtLink>
              <UButton
                color="neutral"
                variant="ghost"
                icon="i-lucide-x"
                aria-label="Close navigation"
                title="Close navigation"
                @click="sidebarOpen = false"
              />
            </div>

            <nav class="space-y-1">
              <NuxtLink
                v-for="item in navigation"
                :key="item.to"
                :to="item.to"
                class="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors"
                :class="isActive(item.to)
                  ? 'bg-attic-500/10 text-attic-500'
                  : 'text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800'"
                @click="sidebarOpen = false"
              >
                <UIcon
                  :name="item.icon"
                  class="w-5 h-5"
                />
                <span
                  class="text-sm"
                  :class="isActive(item.to) ? 'font-bold' : 'font-semibold'"
                >
                  {{ item.label }}
                </span>
              </NuxtLink>

              <div class="pt-4 mt-4 border-t border-gray-100 dark:border-gray-800">
                <NuxtLink
                  v-for="item in secondaryNav"
                  :key="item.to"
                  :to="item.to"
                  class="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors"
                  :class="isActive(item.to)
                    ? 'bg-attic-500/10 text-attic-500'
                    : 'text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800'"
                  @click="sidebarOpen = false"
                >
                  <UIcon
                    :name="item.icon"
                    class="w-5 h-5"
                  />
                  <span
                    class="text-sm"
                    :class="isActive(item.to) ? 'font-bold' : 'font-semibold'"
                  >
                    {{ item.label }}
                  </span>
                </NuxtLink>
              </div>
            </nav>
          </div>

          <div class="shrink-0 p-6 border-t border-gray-100 dark:border-gray-800">
            <NuxtLink
              to="/profile"
              aria-label="Profile"
              class="flex items-center gap-3 rounded-xl p-2 -m-2 transition-colors hover:bg-mist-50 focus-visible:outline-2 focus-visible:outline-attic-500 dark:hover:bg-mist-800"
              :aria-current="route.path === '/profile' ? 'page' : undefined"
              @click="sidebarOpen = false"
            >
              <div class="w-9 h-9 rounded-full bg-attic-500 flex items-center justify-center text-white">
                <UIcon
                  name="i-lucide-user"
                  class="w-4 h-4"
                />
              </div>
              <div class="flex flex-col flex-1 min-w-0">
                <span class="text-sm font-bold text-mist-950 dark:text-white truncate">
                  {{ user?.name || user?.email?.split('@')[0] || 'User' }}
                </span>
                <span class="text-xs text-muted dark:text-gray-400 truncate">
                  {{ user?.email }}
                </span>
                <span class="mt-1 text-xs font-semibold text-attic-600 dark:text-attic-300">View profile</span>
              </div>
              <UIcon
                name="i-lucide-chevron-right"
                class="size-4 shrink-0 text-muted"
              />
            </NuxtLink>
          </div>
        </aside>

        <!-- Main content area -->
        <main class="flex-1 flex flex-col h-full overflow-hidden">
          <!-- Top header bar (mobile only) -->
          <header class="lg:hidden h-16 flex items-center justify-between px-4 bg-white dark:bg-mist-900 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
            <UButton
              color="neutral"
              variant="ghost"
              icon="i-lucide-menu"
              aria-label="Open navigation"
              title="Open navigation"
              @click="sidebarOpen = true"
            />

            <NuxtLink
              to="/"
              class="flex items-center gap-2"
            >
              <div class="bg-attic-500 rounded-lg size-8 flex items-center justify-center text-white">
                <UIcon
                  name="i-lucide-archive"
                  class="w-4 h-4"
                />
              </div>
              <span class="font-extrabold text-mist-950 dark:text-white">Attic</span>
            </NuxtLink>

            <UColorModeButton />
          </header>

          <!-- Scrollable content -->
          <div class="flex-1 overflow-y-auto custom-scrollbar p-4 md:p-6 xl:p-7">
            <div class="mx-auto flex min-h-full max-w-[1440px] flex-col">
              <div class="flex-1">
                <NuxtPage v-if="featuresLoaded && currentRouteEnabled && (!requiresCurrency || organizationSettingsLoaded)" />
                <div
                  v-else-if="featureError"
                  role="alert"
                  class="mx-auto max-w-lg rounded-2xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-900/60 dark:bg-red-950/20"
                >
                  <h2 class="font-extrabold text-mist-950 dark:text-white">
                    Feature settings could not be loaded
                  </h2>
                  <p class="mt-2 text-sm text-muted">
                    Try again before continuing to your inventory.
                  </p>
                  <UButton
                    class="mt-4"
                    icon="i-lucide-refresh-cw"
                    @click="retryFeatureLoad"
                  >
                    Try again
                  </UButton>
                </div>
                <div
                  v-else-if="featuresLoaded && requiresCurrency && organizationSettingsError"
                  role="alert"
                  class="mx-auto max-w-lg rounded-2xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-900/60 dark:bg-red-950/20"
                >
                  <h2 class="font-extrabold text-mist-950 dark:text-white">
                    Currency settings could not be loaded
                  </h2>
                  <p class="mt-2 text-sm text-muted">
                    Prices are hidden until your organization’s currency is available.
                  </p>
                  <UButton
                    class="mt-4"
                    icon="i-lucide-refresh-cw"
                    @click="loadOrganizationSettings"
                  >
                    Try again
                  </UButton>
                </div>
                <p
                  v-else-if="featuresLoaded && requiresCurrency && !organizationSettingsLoaded"
                  role="status"
                  class="p-6 text-center text-sm text-muted"
                >
                  Loading currency settings…
                </p>
              </div>

              <footer class="shrink-0 pt-6 text-center text-xs font-medium text-muted dark:text-mist-400">
                <span>Attic version {{ softwareVersion }}</span>
                <span class="mx-2 text-mist-300 dark:text-mist-600">·</span>
                <a
                  :href="apiDocsUrl"
                  target="_blank"
                  rel="noopener noreferrer"
                  class="text-attic-600 underline decoration-attic-300 underline-offset-2 transition-colors hover:text-attic-700 dark:text-attic-300 dark:decoration-attic-500 dark:hover:text-attic-200"
                >
                  API
                </a>
              </footer>
            </div>
          </div>
        </main>

        <!-- Mobile FAB -->
        <NuxtLink
          v-if="!isAssetForm && !['/plugins', '/profile', '/api-keys'].includes(route.path.replace(/\/$/, ''))"
          to="/assets/new"
          aria-label="Add asset"
          title="Add asset"
          class="lg:hidden fixed bottom-6 right-6 size-14 rounded-2xl bg-attic-500 text-white shadow-primary flex items-center justify-center hover:scale-105 active:scale-95 transition-all z-50"
        >
          <UIcon
            name="i-lucide-plus"
            class="w-7 h-7"
          />
        </NuxtLink>
      </div>
    </template>
  </UApp>
</template>