11<script setup lang="ts">
2- import type { Component } from ' vue'
2+ import {
3+ IconChartAreaLine ,
4+ IconFileStack ,
5+ IconGlobe ,
6+ IconHome ,
7+ IconNetwork ,
8+ IconPlus ,
9+ IconRuler ,
10+ IconSettings ,
11+ IconX ,
12+ } from ' @tabler/icons-vue'
313
4- interface NavItem {
5- href: string
6- name: string
7- icon: Component
8- }
14+ const { t } = useI18n ()
15+ const route = useRoute ()
916
10- defineProps <{
11- navItems: NavItem []
12- }>()
17+ // Primary 4 nav items (around the FAB)
18+ const primaryItems = computed (() => [
19+ { href: ' /overview' , name: t (' overview' ), icon: IconHome },
20+ { href: ' /proxies' , name: t (' proxies' ), icon: IconGlobe },
21+ { href: ' /rules' , name: t (' rules' ), icon: IconRuler },
22+ { href: ' /connections' , name: t (' connections' ), icon: IconNetwork },
23+ ])
1324
14- const route = useRoute ()
25+ // Secondary items in the FAB popup
26+ const secondaryItems = computed (() => [
27+ { href: ' /traffic' , name: t (' dataUsage' ), icon: IconChartAreaLine },
28+ { href: ' /logs' , name: t (' logs' ), icon: IconFileStack },
29+ { href: ' /config' , name: t (' config' ), icon: IconSettings },
30+ ])
1531
1632const isActive = (href : string ) => route .path === href
1733
18- // Entrance animation state
19- const isVisible = ref (false )
34+ // FAB popup state
35+ const popupOpen = ref (false )
36+ const togglePopup = () => {
37+ popupOpen .value = ! popupOpen .value
38+ }
39+ const closePopup = () => {
40+ popupOpen .value = false
41+ }
42+
43+ // Close popup when navigating
44+ watch (() => route .path , closePopup )
45+
46+ // Whether the active route is a secondary item
47+ const isSecondaryActive = computed (() =>
48+ secondaryItems .value .some ((item ) => item .href === route .path ),
49+ )
2050
51+ // Entrance animation
52+ const isVisible = ref (false )
2153onMounted (() => {
22- // Trigger entrance animation
2354 requestAnimationFrame (() => {
2455 isVisible .value = true
2556 })
2657})
2758 </script >
2859
2960<template >
61+ <!-- Backdrop to close popup on outside click -->
62+ <Transition name =" fade" >
63+ <div
64+ v-if =" popupOpen"
65+ class =" fixed inset-0 z-40 lg:hidden"
66+ aria-hidden =" true"
67+ @click =" closePopup"
68+ />
69+ </Transition >
70+
71+ <!-- Secondary popup panel -->
72+ <Transition name =" slide-up" >
73+ <div
74+ v-if =" popupOpen"
75+ class =" fixed inset-x-0 bottom-[4.5rem] z-50 mx-auto w-max lg:hidden"
76+ >
77+ <div
78+ class =" mx-auto flex w-max flex-col overflow-hidden rounded-2xl shadow-xl backdrop-blur-[16px]"
79+ :style =" {
80+ border:
81+ '1px solid color-mix(in oklch, var(--color-base-content) 12%, transparent)',
82+ background:
83+ 'color-mix(in oklch, var(--color-base-300) 95%, transparent)',
84+ }"
85+ >
86+ <NuxtLink
87+ v-for =" item in secondaryItems"
88+ :key =" item.href"
89+ :to =" item.href"
90+ class =" flex items-center gap-3 px-5 py-3 text-sm font-medium no-underline transition-colors duration-200"
91+ :class ="
92+ isActive(item.href)
93+ ? 'text-primary bg-primary/10'
94+ : 'text-base-content/70 hover:text-base-content hover:bg-base-content/5'
95+ "
96+ >
97+ <component :is =" item.icon" class =" h-5 w-5 shrink-0" />
98+ <span >{{ item.name }}</span >
99+ <!-- Active indicator dot -->
100+ <span
101+ v-if =" isActive(item.href)"
102+ class =" ml-auto h-1.5 w-1.5 rounded-full bg-primary"
103+ />
104+ </NuxtLink >
105+ </div >
106+ </div >
107+ </Transition >
108+
109+ <!-- Bottom nav bar -->
30110 <nav
31111 aria-label =" Mobile bottom navigation"
32112 class =" fixed inset-x-0 bottom-0 z-50 transition-all duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] lg:hidden"
33113 :class ="
34114 isVisible ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0'
35115 "
36116 >
37- <!-- Backdrop blur container -->
38117 <div
39- class =" mx-1 mb-2 overflow-hidden rounded-2xl shadow-lg backdrop-blur-[12px] sm:mx-2"
118+ class =" mx-1 mb-2 overflow-visible rounded-2xl shadow-lg backdrop-blur-[12px] sm:mx-2"
40119 :style =" {
41120 border:
42121 '1px solid color-mix(in oklch, var(--color-base-content) 10%, transparent)',
43122 background:
44123 'color-mix(in oklch, var(--color-base-300) 90%, transparent)',
45124 }"
46125 >
47- <div class =" grid h-16 w-full grid-cols-7" >
126+ <div class =" grid h-16 w-full grid-cols-5" >
127+ <!-- Left 2 items: Overview, Proxies -->
48128 <NuxtLink
49- v-for =" nav in navItems "
129+ v-for =" nav in primaryItems.slice(0, 2) "
50130 :key =" nav.href"
51131 :to =" nav.href"
52- class =" group relative flex flex-col items-center justify-center gap-0.5 no-underline transition-all duration-300 ease-in-out active:scale-90"
132+ class =" group relative flex flex-col items-center justify-center gap-0.5 no-underline transition-all duration-200 ease-in-out active:scale-90"
53133 :class ="
54134 isActive(nav.href)
55135 ? 'text-primary'
@@ -65,32 +145,103 @@ onMounted(() => {
65145 : 'bg-transparent group-hover:bg-base-content/5'
66146 "
67147 />
68-
69148 <!-- Active indicator pill -->
70149 <div
71150 class =" absolute top-1 h-1 rounded-full bg-primary transition-all duration-300 ease-in-out"
72151 :class =" isActive(nav.href) ? 'w-8 opacity-100' : 'w-0 opacity-0'"
73152 />
74-
75- <!-- Icon with scale animation -->
76153 <div
77- class =" relative z-10 transition-all duration-300 ease-in-out group-hover:scale-105"
78- :class ="
79- isActive(nav.href) ? 'scale-110 text-xl' : 'scale-100 text-lg'
80- "
154+ class =" relative z-10 transition-transform duration-300 ease-in-out group-hover:scale-105"
155+ :class =" isActive(nav.href) ? 'scale-110' : 'scale-100'"
81156 >
82- <component :is =" nav.icon" />
157+ <component :is =" nav.icon" class = " h-5 w-5 " />
83158 </div >
159+ <span class =" sr-only" >{{ `Navigate to ${nav.name}` }}</span >
160+ <span
161+ aria-hidden =" true"
162+ class =" relative z-10 truncate px-0.5 text-[9px] font-medium transition-all duration-300 sm:text-[10px]"
163+ :class =" isActive(nav.href) ? 'opacity-100' : 'opacity-80'"
164+ >
165+ {{ nav.name }}
166+ </span >
167+ </NuxtLink >
84168
85- <!-- Screen reader label -->
86- <span class =" sr-only" >
87- {{ `Navigate to ${nav.name}` }}
169+ <!-- Center FAB button -->
170+ <div class =" relative flex items-center justify-center" >
171+ <button
172+ class =" group relative -top-3 flex h-14 w-14 flex-col items-center justify-center rounded-2xl shadow-lg transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] active:scale-90"
173+ :class ="
174+ popupOpen || isSecondaryActive
175+ ? 'bg-primary text-primary-content shadow-primary/40'
176+ : 'bg-base-content/10 text-base-content hover:bg-base-content/15'
177+ "
178+ :style ="
179+ popupOpen || isSecondaryActive
180+ ? 'box-shadow: 0 4px 20px color-mix(in oklch, var(--color-primary) 40%, transparent)'
181+ : ''
182+ "
183+ aria-label =" More menu"
184+ @click =" togglePopup"
185+ >
186+ <Transition name =" icon-spin" mode =" out-in" >
187+ <IconX v-if =" popupOpen" class =" h-6 w-6" />
188+ <IconPlus v-else class =" h-6 w-6" />
189+ </Transition >
190+ <!-- Indicator dot when a secondary page is active -->
191+ <span
192+ v-if =" isSecondaryActive && !popupOpen"
193+ class =" absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-base-300 bg-primary"
194+ />
195+ </button >
196+ <span
197+ aria-hidden =" true"
198+ class =" absolute bottom-1 text-[9px] font-medium transition-all duration-300 sm:text-[10px]"
199+ :class ="
200+ popupOpen || isSecondaryActive
201+ ? 'text-primary opacity-100'
202+ : 'text-base-content/60 opacity-80'
203+ "
204+ >
205+ {{ t('more') }}
88206 </span >
207+ </div >
89208
90- <!-- Visual label with fade animation -->
209+ <!-- Right 2 items: Rules, Connections -->
210+ <NuxtLink
211+ v-for =" nav in primaryItems.slice(2, 4)"
212+ :key =" nav.href"
213+ :to =" nav.href"
214+ class =" group relative flex flex-col items-center justify-center gap-0.5 no-underline transition-all duration-200 ease-in-out active:scale-90"
215+ :class ="
216+ isActive(nav.href)
217+ ? 'text-primary'
218+ : 'text-base-content/60 hover:text-base-content'
219+ "
220+ >
221+ <!-- Active background glow -->
222+ <div
223+ class =" absolute inset-1 rounded-xl transition-all duration-300 ease-in-out"
224+ :class ="
225+ isActive(nav.href)
226+ ? 'bg-primary/10'
227+ : 'bg-transparent group-hover:bg-base-content/5'
228+ "
229+ />
230+ <!-- Active indicator pill -->
231+ <div
232+ class =" absolute top-1 h-1 rounded-full bg-primary transition-all duration-300 ease-in-out"
233+ :class =" isActive(nav.href) ? 'w-8 opacity-100' : 'w-0 opacity-0'"
234+ />
235+ <div
236+ class =" relative z-10 transition-transform duration-300 ease-in-out group-hover:scale-105"
237+ :class =" isActive(nav.href) ? 'scale-110' : 'scale-100'"
238+ >
239+ <component :is =" nav.icon" class =" h-5 w-5" />
240+ </div >
241+ <span class =" sr-only" >{{ `Navigate to ${nav.name}` }}</span >
91242 <span
92243 aria-hidden =" true"
93- class =" relative z-10 truncate px-0.5 text-[9px] font-medium transition-all duration-300 ease-in-out group-hover:opacity-100 sm:text-[10px]"
244+ class =" relative z-10 truncate px-0.5 text-[9px] font-medium transition-all duration-300 sm:text-[10px]"
94245 :class =" isActive(nav.href) ? 'opacity-100' : 'opacity-80'"
95246 >
96247 {{ nav.name }}
@@ -100,3 +251,40 @@ onMounted(() => {
100251 </div >
101252 </nav >
102253</template >
254+
255+ <style scoped>
256+ /* Popup slide-up transition */
257+ .slide-up-enter-active ,
258+ .slide-up-leave-active {
259+ transition : all 0.25s cubic-bezier (0.34 , 1.56 , 0.64 , 1 );
260+ }
261+ .slide-up-enter-from ,
262+ .slide-up-leave-to {
263+ opacity : 0 ;
264+ transform : translateY (12px ) scale (0.95 );
265+ }
266+
267+ /* Backdrop fade */
268+ .fade-enter-active ,
269+ .fade-leave-active {
270+ transition : opacity 0.2s ease ;
271+ }
272+ .fade-enter-from ,
273+ .fade-leave-to {
274+ opacity : 0 ;
275+ }
276+
277+ /* Icon spin for + <-> X */
278+ .icon-spin-enter-active ,
279+ .icon-spin-leave-active {
280+ transition : all 0.2s ease ;
281+ }
282+ .icon-spin-enter-from {
283+ opacity : 0 ;
284+ transform : rotate (-90deg ) scale (0.5 );
285+ }
286+ .icon-spin-leave-to {
287+ opacity : 0 ;
288+ transform : rotate (90deg ) scale (0.5 );
289+ }
290+ </style >
0 commit comments