Files
analiticaNucleo/nuxt4-app/app/components/app/AppSidebar.vue
josedario87 a10d39a179
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 49s
Refactor: Implementación impecable de la sidebar con estado unificado
Soluciona todos los problemas identificados en la arquitectura anterior:

Cambios principales:
- Nuevo composable useSidebarState() que centraliza todo el estado
- Elimina múltiples fuentes de verdad que causaban desincronización
- Remueve watchers en cascada y hooks indirectos
- Elimina workarounds manuales de DOM y aria-hidden
- Implementa persistencia consistente en cookies
- Manejo responsive automático (mobile vs desktop)

Archivos modificados:
- app/composables/useSidebarState.ts (nuevo): Composable singleton
- app/components/app/AppSidebar.vue: Usa el nuevo composable
- app/layouts/dashboard.vue: Simplificado, sin refs locales ni workarounds
- docs/SIDEBAR_ARCHITECTURE.md (nuevo): Documentación completa

Beneficios:
✓ Estado consistente en toda la aplicación
✓ No más flickering o comportamientos anómalos
✓ Código más simple y mantenible
✓ Mejor performance (menos re-renders)
✓ Auto-close en mobile al navegar

Referencias:
- app/composables/useSidebarState.ts:1
- app/components/app/AppSidebar.vue:232
- app/layouts/dashboard.vue:40
2025-10-30 11:16:15 -06:00

333 lines
12 KiB
Vue

<template>
<UDashboardSidebar
v-model:open="open"
v-model:collapsed="collapsed"
collapsible
resizable
:default-size="28"
:min-size="20"
:max-size="38"
:toggle="{ color: 'primary', variant: 'subtle', class: 'rounded-full' }"
>
<template #header="{ collapsed: isCollapsed }">
<div class="flex items-center gap-2">
<img
v-if="!isCollapsed"
src="/logo.png"
alt="Analítica Núcleo"
class="h-8 w-8 rounded-full border border-[#ffe0a0]/40"
/>
<UIcon v-else name="i-lucide-activity" class="size-5 text-[#ffe0a0]" />
<span v-if="!isCollapsed" class="text-sm font-semibold text-[var(--brand-text)]">Analítica Núcleo</span>
</div>
</template>
<template #default="{ collapsed: isCollapsed }">
<UButton
:label="isCollapsed ? undefined : 'Buscar...'"
icon="i-lucide-search"
color="neutral"
variant="outline"
block
:square="isCollapsed"
class="mb-4"
>
<template v-if="!isCollapsed" #trailing>
<div class="flex items-center gap-0.5 ms-auto text-[var(--brand-text-muted)]">
<UKbd value="⌘" variant="subtle" />
<UKbd value="K" variant="subtle" />
</div>
</template>
</UButton>
<UNavigationMenu
:collapsed="isCollapsed"
:items="navigationPrimary"
orientation="vertical"
class="gap-1"
/>
</template>
<template #footer="{ collapsed: isCollapsed }">
<div v-if="isAuthenticated" class="space-y-3">
<!-- User Profile Section -->
<div
v-if="!isCollapsed"
class="p-0 space-y-2"
>
<!-- User Info Card -->
<div class="px-3 py-2.5 bg-gradient-to-br from-primary-50/50 to-transparent dark:from-primary-950/20 dark:to-transparent rounded-lg border border-primary-100/60 dark:border-primary-900/40">
<div class="flex items-center gap-3">
<div class="relative flex-shrink-0">
<UAvatar
v-bind="userAvatar"
size="md"
:ui="{
wrapper: 'ring-2 ring-primary-400/50 dark:ring-primary-500/40 shadow-sm'
}"
/>
<span
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-500 border-2 border-white dark:border-gray-950 rounded-full shadow-sm"
title="En línea"
/>
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm text-gray-900 dark:text-white truncate leading-tight">
{{ user.name || user.username }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{{ user.email }}
</p>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="space-y-1">
<UButton
to="https://inicio.nucleoriofrio.com"
color="neutral"
variant="ghost"
size="sm"
block
class="justify-start gap-2.5 hover:bg-gradient-to-r hover:from-primary-50/90 hover:to-green-50/60 dark:hover:from-primary-950/40 dark:hover:to-green-950/30 transition-all duration-300 hover:shadow-sm"
:ui="{
rounded: 'rounded-lg',
padding: { sm: 'px-2.5 py-2' }
}"
>
<template #leading>
<div class="w-7 h-7 rounded-md bg-gradient-to-br from-primary-50 to-green-50 dark:from-primary-950/40 dark:to-green-950/40 flex items-center justify-center flex-shrink-0 relative overflow-hidden shadow-sm border border-primary-100/40 dark:border-primary-900/30">
<img
src="/perfil-icon.png"
alt="Perfil"
class="w-4 h-4 absolute inset-0 m-auto object-cover rounded-sm opacity-90"
@error="handlePerfilIconError"
/>
<UIcon name="i-lucide-home" class="size-3.5 text-green-600 dark:text-green-400 opacity-0" />
</div>
</template>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Inicio</span>
</UButton>
<UButton
to="/settings"
color="neutral"
variant="ghost"
size="sm"
block
class="justify-start gap-2.5 hover:bg-primary-50/80 dark:hover:bg-primary-950/30 transition-all duration-200"
:ui="{
rounded: 'rounded-lg',
padding: { sm: 'px-2.5 py-2' }
}"
>
<template #leading>
<div class="w-7 h-7 rounded-md bg-gray-50 dark:bg-gray-800/50 flex items-center justify-center flex-shrink-0">
<UIcon name="i-lucide-settings" class="size-3.5 text-gray-600 dark:text-gray-400" />
</div>
</template>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Configuración</span>
</UButton>
<UButton
to="/notifications"
color="neutral"
variant="ghost"
size="sm"
block
class="justify-start gap-2.5 hover:bg-primary-50/80 dark:hover:bg-primary-950/30 transition-all duration-200"
:ui="{
rounded: 'rounded-lg',
padding: { sm: 'px-2.5 py-2' }
}"
>
<template #leading>
<div class="w-7 h-7 rounded-md bg-amber-50 dark:bg-amber-950/30 flex items-center justify-center relative flex-shrink-0">
<UIcon name="i-lucide-bell" class="size-3.5 text-amber-600 dark:text-amber-400" />
<span class="absolute -top-0.5 -right-0.5 w-2 h-2 bg-red-500 rounded-full ring-1 ring-white dark:ring-gray-900" />
</div>
</template>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notificaciones</span>
<template #trailing>
<UBadge color="red" variant="solid" size="xs" class="ml-auto">3</UBadge>
</template>
</UButton>
<div class="pt-2 mt-1.5 border-t border-gray-200/60 dark:border-gray-800/60">
<UButton
color="neutral"
variant="ghost"
size="sm"
block
class="justify-start gap-2.5 hover:bg-red-50/80 dark:hover:bg-red-950/30 transition-all duration-200 group"
:ui="{
rounded: 'rounded-lg',
padding: { sm: 'px-2.5 py-2' }
}"
@click="logout"
>
<template #leading>
<div class="w-7 h-7 rounded-md bg-red-50 dark:bg-red-950/30 flex items-center justify-center flex-shrink-0 group-hover:bg-red-100 dark:group-hover:bg-red-950/50 transition-colors">
<UIcon name="i-lucide-log-out" class="size-3.5 text-red-600 dark:text-red-400" />
</div>
</template>
<span class="text-sm font-medium text-red-600 dark:text-red-400">Cerrar sesión</span>
</UButton>
</div>
</div>
</div>
<!-- Collapsed View -->
<div v-else class="flex flex-col items-center gap-3">
<div class="relative group">
<UButton
color="neutral"
variant="ghost"
square
class="relative hover:bg-primary-50/50 dark:hover:bg-primary-950/20 transition-all duration-200"
:ui="{ rounded: 'rounded-lg' }"
>
<div class="relative">
<UAvatar
v-bind="userAvatar"
size="sm"
:ui="{
wrapper: 'ring-2 ring-primary-400/40 dark:ring-primary-500/30 group-hover:ring-primary-500/60 transition-all duration-200 shadow-sm'
}"
/>
<span
class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-500 border-2 border-white dark:border-gray-950 rounded-full shadow-sm"
/>
</div>
</UButton>
</div>
<div class="w-full h-px bg-gray-200/50 dark:bg-gray-800/50" />
<UButton
@click="logout"
color="neutral"
variant="ghost"
square
class="hover:bg-red-50 dark:hover:bg-red-950/20 transition-all duration-200"
:ui="{ rounded: 'rounded-lg' }"
>
<UIcon name="i-lucide-log-out" class="size-4 text-red-600 dark:text-red-400" />
</UButton>
</div>
</div>
</template>
</UDashboardSidebar>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import type { NavigationMenuItem } from '@nuxt/ui'
const route = useRoute()
// Usar el composable unificado para el estado de la sidebar
const sidebarState = useSidebarState()
// Exponer como models para compatibilidad con UDashboardSidebar
const open = computed({
get: () => sidebarState.open.value,
set: (value: boolean) => sidebarState.setOpen(value)
})
const collapsed = computed({
get: () => sidebarState.collapsed.value,
set: (value: boolean) => sidebarState.setCollapsed(value)
})
// Manejo del fallback de iconos para el botón Inicio
const perfilIconFallbackIndex = ref(0)
const perfilIconSources = [
'/perfil-icon.png',
'https://inicio.nucleoriofrio.com/apple-touch-icon.png',
'https://inicio.nucleoriofrio.com/favicon.ico'
]
const handlePerfilIconError = (event: Event) => {
const img = event.target as HTMLImageElement
perfilIconFallbackIndex.value++
if (perfilIconFallbackIndex.value < perfilIconSources.length) {
img.src = perfilIconSources[perfilIconFallbackIndex.value]
} else {
// Si todos los iconos fallan, mostrar el icono de casa
img.style.display = 'none'
const icon = img.nextElementSibling as HTMLElement
if (icon) {
icon.style.opacity = '1'
}
}
}
const navigationPrimary = computed<NavigationMenuItem[]>(() => [
{
label: 'Inicio',
icon: 'i-lucide-home',
to: '/',
active: route.path === '/'
},
{
label: 'Panorama Facturador',
icon: 'i-lucide-bar-chart-3',
to: '/panorama',
active: route.path === '/panorama'
},
{
label: 'Informe Ingresos',
icon: 'i-lucide-file-bar-chart',
to: '/informe-ingresos',
active: route.path === '/informe-ingresos',
badge: { label: 'Mantenimiento', color: 'amber' }
},
{
label: 'Comparativa Cosechas',
icon: 'i-lucide-calendar-range',
to: '/comparativa-cosechas',
active: route.path === '/comparativa-cosechas',
badge: { label: 'Mantenimiento', color: 'amber' }
},
{
label: 'Explorador de datos',
icon: 'i-lucide-table',
to: '/explorer',
active: route.path === '/explorer'
},
{
label: 'Metadatos',
icon: 'i-lucide-database',
to: '/metadatos',
active: route.path === '/metadatos',
badge: { label: 'Mantenimiento', color: 'amber' }
},
{
label: 'Metabase Debug',
icon: 'i-lucide-bug',
to: '/metabase-debug',
active: route.path === '/metabase-debug'
},
{
label: 'Explorador de datos raw',
icon: 'i-lucide-table',
to: '/rawExplorer',
active: route.path === '/rawExplorer',
badge: { label: 'Mantenimiento', color: 'amber' }
}
])
const { user, isAuthenticated, logout } = useAuthentik()
// Computed para el avatar del usuario
const userAvatar = computed(() => ({
src: user.value?.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.value?.name || user.value?.username || 'User')}&background=3b82f6&color=fff&bold=true&format=svg`,
alt: user.value?.name || user.value?.username || 'User'
}))
</script>