frontend/app/app.vue
Rama main · réplica de lectura del repositorio original.
<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>