Files
Inventory_frontend/app/components/layout/AppNavbar.vue
Matthieu b0124c11ba feat(ui) : add site colors, dark mode toggle and card styling improvements
- Site color field with color picker in create/edit modals
- Dark mode theme (mytheme-dark) with toggle in navbar
- Stronger site color visibility on cards (gradient, top border, badges)
- Bigger action buttons (btn-sm) on machine cards
- White card backgrounds with proper dark mode support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:51:29 +01:00

467 lines
16 KiB
Vue

<template>
<div class="navbar navbar-glass sticky top-0 z-50 px-4 lg:px-6">
<div class="navbar-start">
<!-- Mobile hamburger menu -->
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm lg:hidden">
<IconLucideMenu class="w-5 h-5" aria-hidden="true" />
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content mt-3 z-[1] p-3 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
>
<li class="pt-1 pb-2 lg:hidden">
<button
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
@click="toggleDarkMode"
>
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
{{ isDark ? 'Mode clair' : 'Mode sombre' }}
</button>
</li>
<li class="pt-1 pb-2 lg:hidden">
<button
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
@click="$emit('open-settings')"
>
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
Paramètres d'affichage
</button>
</li>
<!-- Mobile: simple links -->
<li v-for="link in simpleLinks" :key="link.to">
<NuxtLink
:to="link.to"
class="rounded-lg px-3 py-2 transition-all flex items-center gap-2"
:class="linkClass(link)"
>
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
{{ link.label }}
</NuxtLink>
</li>
<!-- Mobile: dropdown groups -->
<li
v-for="group in navGroups"
:key="group.id + '-mobile'"
class="mt-1 border-t border-base-200 pt-2"
>
<button
type="button"
class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left transition-all"
:class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-mobile'"
@click="toggleDropdown(group.id + '-mobile')"
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
>
<span class="flex items-center gap-2">
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }}
</span>
<IconLucideChevronRight
class="h-3.5 w-3.5 transition-transform duration-200"
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-mobile">
<ul
v-if="openDropdown === group.id + '-mobile'"
class="mt-1 space-y-0.5 rounded-lg bg-base-200/50 p-2 overflow-hidden"
>
<li v-for="child in group.children" :key="child.to">
<NuxtLink
:to="child.to"
class="rounded-md px-3 py-1.5 transition-colors block text-sm"
:class="childLinkClass(child)"
>
{{ child.label }}
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
{{ unresolvedCount }}
</span>
</NuxtLink>
</li>
</ul>
</Transition>
</li>
</ul>
</div>
<!-- Logo -->
<NuxtLink to="/" class="flex items-center gap-2.5 group">
<div class="w-9 h-9 rounded-lg overflow-hidden ring-1 ring-base-300/50 transition-all group-hover:ring-primary/30 group-hover:shadow-md">
<img
:src="logoSrc"
alt="Logo Malio"
class="h-full w-full object-contain"
/>
</div>
<span class="text-lg font-bold tracking-tight text-base-content hidden sm:inline" style="font-family: var(--font-heading)">
Inventory
</span>
</NuxtLink>
</div>
<!-- Desktop navbar -->
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal gap-0.5 px-1">
<!-- Desktop: simple links -->
<li v-for="link in simpleLinks" :key="link.to">
<NuxtLink
:to="link.to"
class="transition-all px-3 py-2 rounded-lg flex items-center gap-1.5 text-sm font-medium"
:class="linkClass(link)"
>
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
{{ link.label }}
</NuxtLink>
</li>
<!-- Desktop: dropdown groups -->
<li
v-for="group in navGroups"
:key="group.id + '-desktop'"
class="relative"
@mouseenter="setDropdown(group.id + '-desktop')"
@mouseleave="scheduleDropdownClose(group.id + '-desktop')"
@focusin="setDropdown(group.id + '-desktop')"
@focusout="scheduleDropdownClose(group.id + '-desktop')"
>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 transition-all text-sm font-medium"
:class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-desktop'"
@click="toggleDropdown(group.id + '-desktop')"
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
>
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }}
<IconLucideChevronDown
class="h-3.5 w-3.5 transition-transform duration-200"
:class="openDropdown === group.id + '-desktop' ? 'rotate-180' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-desktop">
<ul
v-if="openDropdown === group.id + '-desktop'"
class="absolute left-0 top-full mt-1.5 w-56 rounded-xl border border-base-300/50 bg-base-100 p-1.5 shadow-lg shadow-base-content/5 z-50"
>
<li v-for="child in group.children" :key="child.to">
<NuxtLink
:to="child.to"
class="block rounded-lg px-3 py-2 transition-all text-sm"
:class="childLinkClass(child)"
>
{{ child.label }}
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
{{ unresolvedCount }}
</span>
</NuxtLink>
</li>
</ul>
</Transition>
</li>
</ul>
</div>
<!-- Navbar end -->
<div class="navbar-end">
<div class="flex items-center gap-1.5">
<button
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
:title="isDark ? 'Mode clair' : 'Mode sombre'"
@click="toggleDarkMode"
>
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
</button>
<button
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
title="Paramètres d'affichage"
@click="$emit('open-settings')"
>
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
</button>
<ClientOnly>
<div v-if="activeProfile" class="dropdown dropdown-end">
<div
tabindex="0"
role="button"
class="indicator cursor-pointer"
>
<span
v-if="unresolvedCount > 0"
class="indicator-item badge badge-warning badge-xs"
>
{{ unresolvedCount }}
</span>
<div
class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
>
<span class="text-xs font-semibold">
{{ activeProfileInitials }}
</span>
</div>
</div>
<ul
tabindex="0"
class="menu dropdown-content mt-3 p-2 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
>
<li class="px-3 py-2">
<div class="flex flex-col gap-1 pointer-events-none">
<span class="text-xs text-base-content/50">Connecté en tant que</span>
<span class="font-semibold text-sm text-base-content">{{ activeProfileLabel }}</span>
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
</div>
</li>
<div class="divider my-0.5 px-2" />
<li v-if="isAdmin">
<NuxtLink to="/admin" class="rounded-lg justify-between text-sm">
Administration
<IconLucideChevronRight class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
</NuxtLink>
</li>
<li>
<NuxtLink to="/comments" class="rounded-lg justify-between text-sm">
Commentaires
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
{{ unresolvedCount }}
</span>
<IconLucideChevronRight v-else class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
</NuxtLink>
</li>
<div class="divider my-0.5 px-2" />
<li>
<button
type="button"
class="rounded-lg text-error/80 hover:text-error hover:bg-error/5 justify-between text-sm"
@click="$emit('logout')"
>
Déconnexion
<IconLucideLogOut class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</li>
</ul>
</div>
</ClientOnly>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, type Component } from 'vue'
import { useRoute } from '#imports'
import { useNavDropdown } from '~/composables/useNavDropdown'
import { usePermissions } from '~/composables/usePermissions'
import { useProfileSession } from '~/composables/useProfileSession'
import { useComments } from '~/composables/useComments'
import IconLucideMenu from '~icons/lucide/menu'
import IconLucideSettings from '~icons/lucide/settings'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideLogOut from '~icons/lucide/log-out'
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideCpu from '~icons/lucide/cpu'
import IconLucidePuzzle from '~icons/lucide/puzzle'
import IconLucidePackage from '~icons/lucide/package'
import IconLucideLink from '~icons/lucide/link'
import IconLucideSun from '~icons/lucide/sun'
import IconLucideMoon from '~icons/lucide/moon'
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
defineEmits<{
(e: 'open-settings'): void
(e: 'logout'): void
}>()
interface NavLink {
to: string
label: string
icon?: Component
}
interface NavGroup {
id: string
label: string
icon?: Component
activePaths: string[]
children: NavLink[]
}
const simpleLinks: NavLink[] = [
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
]
const navGroups: NavGroup[] = [
{
id: 'component',
label: 'Composants',
icon: IconLucideCpu,
activePaths: ['/component-category', '/component-catalog'],
children: [
{ to: '/component-catalog', label: 'Catalogue des composants' },
{ to: '/component-category', label: 'Catégorie de composant' },
],
},
{
id: 'pieces',
label: 'Pièces',
icon: IconLucidePuzzle,
activePaths: ['/piece-category', '/pieces-catalog'],
children: [
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
{ to: '/piece-category', label: 'Catégorie de pièce' },
],
},
{
id: 'products',
label: 'Produits',
icon: IconLucidePackage,
activePaths: ['/product-category', '/product-catalog'],
children: [
{ to: '/product-catalog', label: 'Catalogue des produits' },
{ to: '/product-category', label: 'Catégorie de produit' },
],
},
{
id: 'resources',
label: 'Ressources liées',
icon: IconLucideLink,
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
children: [
{ to: '/sites', label: 'Sites' },
{ to: '/documents', label: 'Documents' },
{ to: '/constructeurs', label: 'Fournisseurs' },
{ to: '/comments', label: 'Commentaires' },
{ to: '/activity-log', label: 'Journal d\'activité' },
],
},
]
const route = useRoute()
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
const { activeProfile } = useProfileSession()
const { isAdmin, canEdit } = usePermissions()
const { fetchUnresolvedCount } = useComments()
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
const unresolvedCount = ref(0)
let pollInterval: ReturnType<typeof setInterval> | null = null
const refreshUnresolvedCount = async () => {
if (!activeProfile.value) return
unresolvedCount.value = await fetchUnresolvedCount()
}
onMounted(() => {
initDarkMode()
refreshUnresolvedCount()
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
})
onBeforeUnmount(() => {
if (pollInterval) clearInterval(pollInterval)
})
const isActive = (path: string) => {
if (path === '/') {
return route.path === '/'
}
return route.path.startsWith(path)
}
const isGroupActive = (group: NavGroup) => {
return group.activePaths.some((path) => isActive(path))
}
const linkClass = (link: NavLink) => {
return isActive(link.to)
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
}
const groupClass = (group: NavGroup) => {
return isGroupActive(group)
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
}
const childLinkClass = (child: NavLink) => {
return isActive(child.to)
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
}
const roleLabel = computed(() => {
if (isAdmin.value) return 'Admin'
if (canEdit.value) return 'Gestionnaire'
return 'Lecteur'
})
const roleBadgeClass = computed(() => {
if (isAdmin.value) return 'badge-error'
if (canEdit.value) return 'badge-warning'
return 'badge-info'
})
const activeProfileLabel = computed(() => {
if (!activeProfile.value) {
return 'Profil inconnu'
}
return `${activeProfile.value.firstName} ${activeProfile.value.lastName}`
})
const activeProfileInitials = computed(() => {
if (!activeProfile.value) {
return '??'
}
const { firstName = '', lastName = '' } = activeProfile.value
return (
`${firstName.charAt(0) || ''}${lastName.charAt(0) || ''}`.toUpperCase() || '??'
)
})
</script>
<style scoped>
.nav-dropdown-desktop-enter-active,
.nav-dropdown-desktop-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.nav-dropdown-desktop-enter-from,
.nav-dropdown-desktop-leave-to {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
.nav-dropdown-desktop-enter-to,
.nav-dropdown-desktop-leave-from {
opacity: 1;
transform: translateY(0) scale(1);
}
.nav-dropdown-mobile-enter-active,
.nav-dropdown-mobile-leave-active {
transition: max-height 0.2s ease, opacity 0.2s ease;
}
.nav-dropdown-mobile-enter-from,
.nav-dropdown-mobile-leave-to {
max-height: 0;
opacity: 0;
}
.nav-dropdown-mobile-enter-to,
.nav-dropdown-mobile-leave-from {
max-height: 12rem;
opacity: 1;
}
</style>