[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at eac8537f4da5f46fc166b0e041d492d8091dfbca 292 lines 8.0 kB view raw
1<script setup lang="ts"> 2import { LinkBase } from '#components' 3import type { NavigationConfig, NavigationConfigWithGroups } from '~/types' 4import { isEditableElement } from '~/utils/input' 5 6withDefaults( 7 defineProps<{ 8 showLogo?: boolean 9 }>(), 10 { 11 showLogo: true, 12 }, 13) 14 15const { isConnected, npmUser } = useConnector() 16 17const desktopLinks = computed<NavigationConfig>(() => [ 18 { 19 name: 'Compare', 20 label: $t('nav.compare'), 21 to: { name: 'compare' }, 22 keyshortcut: 'c', 23 type: 'link', 24 external: false, 25 iconClass: 'i-carbon:compare', 26 }, 27 { 28 name: 'Settings', 29 label: $t('nav.settings'), 30 to: { name: 'settings' }, 31 keyshortcut: ',', 32 type: 'link', 33 external: false, 34 iconClass: 'i-carbon:settings', 35 }, 36]) 37 38const mobileLinks = computed<NavigationConfigWithGroups>(() => [ 39 { 40 name: 'Desktop Links', 41 type: 'group', 42 items: [...desktopLinks.value], 43 }, 44 { 45 type: 'separator', 46 }, 47 { 48 name: 'About & Policies', 49 type: 'group', 50 items: [ 51 { 52 name: 'About', 53 label: $t('footer.about'), 54 to: { name: 'about' }, 55 type: 'link', 56 external: false, 57 iconClass: 'i-carbon:information', 58 }, 59 { 60 name: 'Privacy Policy', 61 label: $t('privacy_policy.title'), 62 to: { name: 'privacy' }, 63 type: 'link', 64 external: false, 65 iconClass: 'i-carbon:security', 66 }, 67 { 68 name: 'Accessibility', 69 label: $t('a11y.title'), 70 to: { name: 'accessibility' }, 71 type: 'link', 72 external: false, 73 iconClass: 'i-carbon:accessibility-alt', 74 }, 75 ], 76 }, 77 { 78 type: 'separator', 79 }, 80 { 81 name: 'External Links', 82 type: 'group', 83 label: $t('nav.links'), 84 items: [ 85 { 86 name: 'Docs', 87 label: $t('footer.docs'), 88 href: 'https://docs.npmx.dev', 89 target: '_blank', 90 type: 'link', 91 external: true, 92 iconClass: 'i-carbon:document', 93 }, 94 { 95 name: 'Source', 96 label: $t('footer.source'), 97 href: 'https://repo.npmx.dev', 98 target: '_blank', 99 type: 'link', 100 external: true, 101 iconClass: 'i-carbon:logo-github', 102 }, 103 { 104 name: 'Social', 105 label: $t('footer.social'), 106 href: 'https://social.npmx.dev', 107 target: '_blank', 108 type: 'link', 109 external: true, 110 iconClass: 'i-simple-icons:bluesky', 111 }, 112 { 113 name: 'Chat', 114 label: $t('footer.chat'), 115 href: 'https://chat.npmx.dev', 116 target: '_blank', 117 type: 'link', 118 external: true, 119 iconClass: 'i-carbon:chat', 120 }, 121 ], 122 }, 123]) 124 125const showFullSearch = shallowRef(false) 126const showMobileMenu = shallowRef(false) 127 128// On mobile, clicking logo+search button expands search 129const route = useRoute() 130const isMobile = useIsMobile() 131const isSearchExpandedManually = shallowRef(false) 132const searchBoxRef = useTemplateRef('searchBoxRef') 133 134// On search page, always show search expanded on mobile 135const isOnHomePage = computed(() => route.name === 'index') 136const isOnSearchPage = computed(() => route.name === 'search') 137const isSearchExpanded = computed(() => isOnSearchPage.value || isSearchExpandedManually.value) 138 139function expandMobileSearch() { 140 isSearchExpandedManually.value = true 141 nextTick(() => { 142 searchBoxRef.value?.focus() 143 }) 144} 145 146watch( 147 isOnSearchPage, 148 visible => { 149 if (!visible) return 150 151 searchBoxRef.value?.focus() 152 nextTick(() => { 153 searchBoxRef.value?.focus() 154 }) 155 }, 156 { flush: 'sync' }, 157) 158 159function handleSearchBlur() { 160 showFullSearch.value = false 161 // Collapse expanded search on mobile after blur (with delay for click handling) 162 // But don't collapse if we're on the search page 163 if (isMobile.value && !isOnSearchPage.value) { 164 setTimeout(() => { 165 isSearchExpandedManually.value = false 166 }, 150) 167 } 168} 169 170function handleSearchFocus() { 171 showFullSearch.value = true 172} 173 174onKeyStroke( 175 e => { 176 if (isEditableElement(e.target)) { 177 return 178 } 179 180 for (const link of desktopLinks.value) { 181 if (link.to && link.keyshortcut && isKeyWithoutModifiers(e, link.keyshortcut)) { 182 e.preventDefault() 183 navigateTo(link.to) 184 break 185 } 186 } 187 }, 188 { dedupe: true }, 189) 190</script> 191 192<template> 193 <header class="sticky top-0 z-50 border-b border-border"> 194 <div class="absolute inset-0 bg-bg/80 backdrop-blur-md" /> 195 <nav 196 :aria-label="$t('nav.main_navigation')" 197 class="relative container min-h-14 flex items-center gap-2 z-1" 198 :class="isOnHomePage ? 'justify-end' : 'justify-between'" 199 > 200 <!-- Mobile: Logo + search button (expands search, doesn't navigate) --> 201 <button 202 v-if="!isSearchExpanded && !isOnHomePage" 203 type="button" 204 class="sm:hidden flex-shrink-0 inline-flex items-center gap-2 font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 rounded" 205 :aria-label="$t('nav.tap_to_search')" 206 @click="expandMobileSearch" 207 > 208 <AppLogo class="w-8 h-8 rounded-lg" /> 209 <span class="i-carbon:search w-4 h-4 text-fg-subtle" aria-hidden="true" /> 210 </button> 211 212 <!-- Desktop: Logo (navigates home) --> 213 <div v-if="showLogo" class="hidden sm:flex flex-shrink-0 items-center"> 214 <NuxtLink 215 :to="{ name: 'index' }" 216 :aria-label="$t('header.home')" 217 dir="ltr" 218 class="inline-flex items-center gap-1 header-logo font-mono text-lg font-medium text-fg hover:text-fg/90 transition-colors duration-200 rounded" 219 > 220 <AppLogo class="w-8 h-8 rounded-lg" /> 221 <span>npmx</span> 222 </NuxtLink> 223 </div> 224 <!-- Spacer when logo is hidden on desktop --> 225 <span v-else class="hidden sm:block w-1" /> 226 227 <!-- Center: Search bar + nav items --> 228 <div 229 class="flex-1 flex items-center md:gap-6" 230 :class="{ 231 'hidden sm:flex': !isSearchExpanded, 232 'justify-end': isOnHomePage, 233 'justify-center': !isOnHomePage, 234 }" 235 > 236 <!-- Search bar (hidden on mobile unless expanded) --> 237 <HeaderSearchBox 238 ref="searchBoxRef" 239 :inputClass="isSearchExpanded ? 'w-full' : ''" 240 :class="{ 'max-w-md': !isSearchExpanded }" 241 @focus="handleSearchFocus" 242 @blur="handleSearchBlur" 243 /> 244 <ul 245 v-if="!isSearchExpanded && isConnected && npmUser" 246 :class="{ hidden: showFullSearch }" 247 class="hidden sm:flex items-center gap-4 sm:gap-6 list-none m-0 p-0" 248 > 249 <!-- Packages dropdown (when connected) --> 250 <li v-if="isConnected && npmUser" class="flex items-center"> 251 <HeaderPackagesDropdown :username="npmUser" /> 252 </li> 253 254 <!-- Orgs dropdown (when connected) --> 255 <li v-if="isConnected && npmUser" class="flex items-center"> 256 <HeaderOrgsDropdown :username="npmUser" /> 257 </li> 258 </ul> 259 </div> 260 261 <!-- End: Desktop nav items + Mobile menu button --> 262 <div class="hidden sm:flex flex-shrink-0"> 263 <!-- Desktop: Explore link --> 264 <LinkBase 265 v-for="link in desktopLinks" 266 :key="link.name" 267 class="border-none" 268 variant="button-secondary" 269 :to="link.to" 270 :aria-keyshortcuts="link.keyshortcut" 271 > 272 {{ link.label }} 273 </LinkBase> 274 275 <HeaderAccountMenu /> 276 </div> 277 278 <!-- Mobile: Menu button (always visible, click to open menu) --> 279 <ButtonBase 280 type="button" 281 class="sm:hidden" 282 :aria-label="$t('nav.open_menu')" 283 :aria-expanded="showMobileMenu" 284 @click="showMobileMenu = !showMobileMenu" 285 classicon="i-carbon:menu" 286 /> 287 </nav> 288 289 <!-- Mobile menu --> 290 <HeaderMobileMenu :links="mobileLinks" v-model:open="showMobileMenu" /> 291 </header> 292</template>