[READ-ONLY] a fast, modern browser for the npm registry
at main 227 lines 8.4 kB view raw
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>