forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
3import { useAtproto } from '~/composables/atproto/useAtproto'
4import type { NavigationConfigWithGroups } from '~/types'
5
6const isOpen = defineModel<boolean>('open', { default: false })
7const { links } = defineProps<{
8 links: NavigationConfigWithGroups
9}>()
10
11const { isConnected, npmUser, avatar: npmAvatar } = useConnector()
12const { user: atprotoUser } = useAtproto()
13
14const navRef = useTemplateRef('navRef')
15const { activate, deactivate } = useFocusTrap(navRef, { allowOutsideClick: true })
16
17function closeMenu() {
18 isOpen.value = false
19}
20
21function handleShowConnector() {
22 const connectorModal = document.querySelector<HTMLDialogElement>('#connector-modal')
23 if (connectorModal) {
24 closeMenu()
25 connectorModal.showModal()
26 }
27}
28
29function handleShowAuth() {
30 const authModal = document.querySelector<HTMLDialogElement>('#auth-modal')
31 if (authModal) {
32 closeMenu()
33 authModal.showModal()
34 }
35}
36
37// Close menu on route change
38const route = useRoute()
39watch(() => route.fullPath, closeMenu)
40
41// Close on escape
42onKeyStroke(
43 e => isKeyWithoutModifiers(e, 'Escape') && isOpen.value,
44 e => {
45 isOpen.value = false
46 },
47)
48
49// Prevent body scroll when menu is open
50const isLocked = useScrollLock(document)
51watch(isOpen, open => (isLocked.value = open))
52watch(isOpen, open => (open ? nextTick(activate) : deactivate()))
53onUnmounted(deactivate)
54</script>
55
56<template>
57 <Teleport to="body">
58 <Transition
59 enter-active-class="transition-opacity duration-200"
60 leave-active-class="transition-opacity duration-150"
61 enter-from-class="opacity-0"
62 leave-to-class="opacity-0"
63 >
64 <div
65 v-if="isOpen"
66 class="fixed inset-0 z-[60] sm:hidden"
67 role="dialog"
68 aria-modal="true"
69 :aria-label="$t('nav.mobile_menu')"
70 >
71 <!-- Backdrop -->
72 <button
73 type="button"
74 class="absolute inset-0 bg-black/60 cursor-default"
75 :aria-label="$t('common.close')"
76 @click="closeMenu"
77 />
78
79 <!-- Menu panel (slides in from right) -->
80 <Transition
81 enter-active-class="transition-transform duration-200"
82 enter-from-class="translate-x-full"
83 enter-to-class="translate-x-0"
84 leave-active-class="transition-transform duration-200"
85 leave-from-class="translate-x-0"
86 leave-to-class="translate-x-full"
87 >
88 <nav
89 v-if="isOpen"
90 ref="navRef"
91 class="absolute inset-ie-0 top-0 bottom-0 w-72 bg-bg border-is border-border shadow-xl flex flex-col"
92 >
93 <!-- Header -->
94 <div class="flex items-center justify-between p-4 border-b border-border">
95 <span class="font-mono text-sm text-fg-muted">{{ $t('nav.menu') }}</span>
96 <button
97 type="button"
98 class="p-2 -m-2 text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-accent/70 rounded"
99 :aria-label="$t('common.close')"
100 @click="closeMenu"
101 >
102 <span class="i-lucide:x w-5 h-5" aria-hidden="true" />
103 </button>
104 </div>
105
106 <!-- Account section -->
107 <div class="px-2 py-2">
108 <span
109 class="px-3 py-2 block font-mono text-xs text-fg-subtle uppercase tracking-wider"
110 >
111 {{ $t('account_menu.account') }}
112 </span>
113
114 <!-- npm CLI connection status (only show if connected) -->
115 <button
116 v-if="isConnected && npmUser"
117 type="button"
118 class="w-full flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200 text-start"
119 @click="handleShowConnector"
120 >
121 <img
122 v-if="npmAvatar"
123 :src="npmAvatar"
124 :alt="npmUser"
125 width="20"
126 height="20"
127 class="w-5 h-5 rounded-full object-cover"
128 />
129 <span
130 v-else
131 class="w-5 h-5 rounded-full bg-bg-muted flex items-center justify-center"
132 >
133 <span class="i-lucide:terminal w-3 h-3 text-fg-muted" aria-hidden="true" />
134 </span>
135 <span class="flex-1">~{{ npmUser }}</span>
136 <span class="w-2 h-2 rounded-full bg-green-500" aria-hidden="true" />
137 </button>
138
139 <!-- Atmosphere connection status -->
140 <button
141 v-if="atprotoUser"
142 type="button"
143 class="w-full flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200 text-start"
144 @click="handleShowAuth"
145 >
146 <img
147 v-if="atprotoUser.avatar"
148 :src="atprotoUser.avatar"
149 :alt="atprotoUser.handle"
150 width="20"
151 height="20"
152 class="w-5 h-5 rounded-full object-cover"
153 />
154 <span
155 v-else
156 class="w-5 h-5 rounded-full bg-bg-muted flex items-center justify-center"
157 >
158 <span class="i-lucide:at-sign w-3 h-3 text-fg-muted" aria-hidden="true" />
159 </span>
160 <span class="flex-1 truncate">@{{ atprotoUser.handle }}</span>
161 </button>
162
163 <!-- Connect Atmosphere button (show if not connected) -->
164 <button
165 v-else
166 type="button"
167 class="w-full flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200 text-start"
168 @click="handleShowAuth"
169 >
170 <span class="w-5 h-5 rounded-full bg-bg-muted flex items-center justify-center">
171 <span class="i-lucide:at-sign w-3 h-3 text-fg-muted" aria-hidden="true" />
172 </span>
173 <span class="flex-1">{{ $t('account_menu.connect_atmosphere') }}</span>
174 </button>
175 </div>
176
177 <!-- Divider -->
178 <div class="mx-4 my-2 border-t border-border" />
179
180 <!-- Navigation links -->
181 <div class="flex-1 overflow-y-auto overscroll-contain py-2">
182 <template v-for="(group, index) in links">
183 <div
184 v-if="group.type === 'separator'"
185 :key="`seperator-${index}`"
186 class="mx-4 my-2 border-t border-border"
187 />
188
189 <div v-if="group.type === 'group'" :key="group.name" class="p-2">
190 <span
191 v-if="group.label"
192 class="px-3 py-2 font-mono text-xs text-fg-subtle uppercase tracking-wider"
193 >
194 {{ group.label }}
195 </span>
196 <div>
197 <NuxtLink
198 v-for="link in group.items"
199 :key="link.name"
200 :to="link.to"
201 :href="link.href"
202 :target="link.target"
203 class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200"
204 @click="closeMenu"
205 >
206 <span
207 :class="link.iconClass"
208 class="w-5 h-5 text-fg-muted"
209 aria-hidden="true"
210 />
211 {{ link.label }}
212 <span
213 v-if="link.external"
214 class="i-lucide:external-link rtl-flip w-3 h-3 ms-auto text-fg-subtle"
215 aria-hidden="true"
216 />
217 </NuxtLink>
218 </div>
219 </div>
220 </template>
221 </div>
222 </nav>
223 </Transition>
224 </div>
225 </Transition>
226 </Teleport>
227</template>