[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.

feat: header search bar + settings back button + hide connector (#251)

authored by

Daniel Roe and committed by
GitHub
8a11728d 1f743536

+159 -69
+1 -1
app/app.vue
··· 7 7 // Initialize accent color before hydration to prevent flash 8 8 initAccentOnPrehydrate() 9 9 10 - const isHomepage = computed(() => route.path === '/') 10 + const isHomepage = computed(() => route.name === 'index') 11 11 12 12 useHead({ 13 13 titleTemplate: titleChunk => {
+79 -38
app/components/AppHeader.vue
··· 13 13 const { isConnected, npmUser } = useConnector() 14 14 15 15 const router = useRouter() 16 + const route = useRoute() 17 + 18 + const searchQuery = ref('') 19 + const isSearchFocused = ref(false) 20 + 21 + const showSearchBar = computed(() => { 22 + return route.name !== 'search' && route.name !== 'index' 23 + }) 24 + 25 + async function handleSearchInput() { 26 + const query = searchQuery.value.trim() 27 + await router.push({ 28 + name: 'search', 29 + query: query ? { q: query } : undefined, 30 + }) 31 + searchQuery.value = '' 32 + } 33 + 16 34 onKeyStroke(',', e => { 17 35 // Don't trigger if user is typing in an input 18 36 const target = e.target as HTMLElement ··· 45 63 <span v-else class="w-1" /> 46 64 </div> 47 65 48 - <!-- Center: Main nav items --> 49 - <ul class="flex-1 flex items-center justify-center gap-4 sm:gap-6 list-none m-0 p-0"> 50 - <li class="flex items-center"> 51 - <NuxtLink 52 - to="/search" 53 - class="link-subtle font-mono text-sm inline-flex items-center gap-2" 54 - aria-keyshortcuts="/" 66 + <!-- Center: Search bar + nav items --> 67 + <div class="flex-1 flex items-center justify-center gap-4 sm:gap-6"> 68 + <!-- Search bar (shown on all pages except home and search) --> 69 + <search v-if="showSearchBar" class="hidden sm:block flex-1 max-w-md"> 70 + <form 71 + role="search" 72 + method="GET" 73 + action="/search" 74 + class="relative" 75 + @submit.prevent="handleSearchInput" 55 76 > 56 - {{ $t('nav.search') }} 57 - <kbd 58 - class="hidden sm:inline-flex items-center justify-center w-5 h-5 text-xs bg-bg-muted border border-border rounded" 59 - aria-hidden="true" 60 - > 61 - / 62 - </kbd> 63 - </NuxtLink> 64 - </li> 77 + <label for="header-search" class="sr-only"> 78 + {{ $t('search.label') }} 79 + </label> 80 + 81 + <div class="relative group" :class="{ 'is-focused': isSearchFocused }"> 82 + <div class="search-box relative flex items-center"> 83 + <span 84 + class="absolute left-3 text-fg-subtle font-mono text-sm pointer-events-none transition-colors duration-200 motion-reduce:transition-none group-focus-within:text-accent z-1" 85 + > 86 + / 87 + </span> 88 + 89 + <input 90 + id="header-search" 91 + v-model="searchQuery" 92 + type="search" 93 + name="q" 94 + :placeholder="$t('search.placeholder')" 95 + v-bind="noCorrect" 96 + class="w-full bg-bg-subtle border border-border rounded-md pl-7 pr-3 py-1.5 font-mono text-sm text-fg placeholder:text-fg-subtle transition-border-color duration-300 motion-reduce:transition-none focus:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" 97 + autocomplete="off" 98 + @input="handleSearchInput" 99 + @focus="isSearchFocused = true" 100 + @blur="isSearchFocused = false" 101 + /> 102 + <button type="submit" class="sr-only">{{ $t('search.button') }}</button> 103 + </div> 104 + </div> 105 + </form> 106 + </search> 65 107 66 - <!-- Packages dropdown (when connected) --> 67 - <li v-if="isConnected && npmUser" class="flex items-center"> 68 - <HeaderPackagesDropdown :username="npmUser" /> 69 - </li> 108 + <ul class="flex items-center gap-4 sm:gap-6 list-none m-0 p-0"> 109 + <!-- Packages dropdown (when connected) --> 110 + <li v-if="isConnected && npmUser" class="flex items-center"> 111 + <HeaderPackagesDropdown :username="npmUser" /> 112 + </li> 70 113 71 - <!-- Orgs dropdown (when connected) --> 72 - <li v-if="isConnected && npmUser" class="flex items-center"> 73 - <HeaderOrgsDropdown :username="npmUser" /> 74 - </li> 75 - </ul> 114 + <!-- Orgs dropdown (when connected) --> 115 + <li v-if="isConnected && npmUser" class="flex items-center"> 116 + <HeaderOrgsDropdown :username="npmUser" /> 117 + </li> 118 + </ul> 119 + </div> 76 120 77 121 <!-- Right: User status + GitHub --> 78 - <div class="flex-shrink-0 flex items-center gap-6"> 122 + <div class="flex-shrink-0 flex items-center gap-4 sm:gap-6 ml-auto sm:ml-0"> 123 + <NuxtLink 124 + to="/about" 125 + class="sm:hidden link-subtle font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded" 126 + > 127 + {{ $t('footer.about') }} 128 + </NuxtLink> 129 + 79 130 <NuxtLink 80 131 to="/settings" 81 - class="link-subtle font-mono text-sm inline-flex items-center gap-2" 132 + class="link-subtle font-mono text-sm inline-flex items-center gap-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded" 82 133 aria-keyshortcuts="," 83 134 > 84 135 {{ $t('nav.settings') }} ··· 90 141 </kbd> 91 142 </NuxtLink> 92 143 93 - <div v-if="showConnector"> 144 + <div v-if="showConnector" class="hidden sm:block"> 94 145 <ConnectorStatus /> 95 146 </div> 96 - 97 - <a 98 - href="https://github.com/npmx-dev/npmx.dev" 99 - target="_blank" 100 - rel="noopener noreferrer" 101 - class="link-subtle" 102 - :aria-label="$t('header.github')" 103 - > 104 - <span class="i-carbon-logo-github w-5 h-5" aria-hidden="true" /> 105 - </a> 106 147 </div> 107 148 </nav> 108 149 </header>
+48 -24
app/components/PackageList.vue
··· 3 3 import type { WindowVirtualizerHandle } from '~/composables/useVirtualInfiniteScroll' 4 4 import { WindowVirtualizer } from 'virtua/vue' 5 5 6 + /** Number of items to render statically during SSR */ 7 + const SSR_COUNT = 20 8 + 6 9 const props = defineProps<{ 7 10 /** List of search results to display */ 8 11 results: NpmSearchResult[] ··· 96 99 97 100 <template> 98 101 <div> 99 - <WindowVirtualizer 100 - ref="listRef" 101 - :data="results" 102 - :item-size="140" 103 - as="ol" 104 - item="li" 105 - class="list-none m-0 p-0" 106 - @scroll="handleScroll" 107 - > 108 - <template #default="{ item, index }"> 109 - <div class="pb-4"> 110 - <PackageCard 111 - :result="item as NpmSearchResult" 112 - :heading-level="headingLevel" 113 - :show-publisher="showPublisher" 114 - :selected="index === (selectedIndex ?? -1)" 115 - :index="index" 116 - :search-query="searchQuery" 117 - class="motion-safe:animate-fade-in motion-safe:animate-fill-both" 118 - :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" 119 - @focus="emit('select', $event)" 120 - /> 121 - </div> 102 + <!-- SSR: Render static list for first page, replaced by virtual list on client --> 103 + <ClientOnly> 104 + <WindowVirtualizer 105 + ref="listRef" 106 + :data="results" 107 + :item-size="140" 108 + as="ol" 109 + item="li" 110 + class="list-none m-0 p-0" 111 + @scroll="handleScroll" 112 + > 113 + <template #default="{ item, index }"> 114 + <div class="pb-4"> 115 + <PackageCard 116 + :result="item as NpmSearchResult" 117 + :heading-level="headingLevel" 118 + :show-publisher="showPublisher" 119 + :selected="index === (selectedIndex ?? -1)" 120 + :index="index" 121 + :search-query="searchQuery" 122 + class="motion-safe:animate-fade-in motion-safe:animate-fill-both" 123 + :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" 124 + @focus="emit('select', $event)" 125 + /> 126 + </div> 127 + </template> 128 + </WindowVirtualizer> 129 + 130 + <!-- SSR fallback: static list of first page results --> 131 + <template #fallback> 132 + <ol class="list-none m-0 p-0"> 133 + <li v-for="(item, index) in results.slice(0, SSR_COUNT)" :key="item.package.name"> 134 + <div class="pb-4"> 135 + <PackageCard 136 + :result="item" 137 + :heading-level="headingLevel" 138 + :show-publisher="showPublisher" 139 + :selected="index === (selectedIndex ?? -1)" 140 + :index="index" 141 + :search-query="searchQuery" 142 + /> 143 + </div> 144 + </li> 145 + </ol> 122 146 </template> 123 - </WindowVirtualizer> 147 + </ClientOnly> 124 148 125 149 <!-- Loading indicator --> 126 150 <div v-if="isLoading" class="py-4 flex items-center justify-center">
+7 -1
app/pages/index.vue
··· 41 41 class="w-full max-w-xl motion-safe:animate-slide-up motion-safe:animate-fill-both" 42 42 style="animation-delay: 0.2s" 43 43 > 44 - <form role="search" class="relative" @submit.prevent="handleSearch"> 44 + <form 45 + role="search" 46 + method="GET" 47 + action="/search" 48 + class="relative" 49 + @submit.prevent="handleSearch" 50 + > 45 51 <label for="home-search" class="sr-only"> 46 52 {{ $t('search.label') }} 47 53 </label>
+1 -1
app/pages/search.vue
··· 706 706 <h1 class="font-mono text-xl sm:text-2xl font-medium mb-4">search</h1> 707 707 708 708 <search> 709 - <form role="search" class="relative" @submit.prevent> 709 + <form role="search" method="GET" action="/search" class="relative" @submit.prevent> 710 710 <label for="search-input" class="sr-only">{{ $t('search.label') }}</label> 711 711 712 712 <div class="relative group" :class="{ 'is-focused': isSearchFocused }">
+15
app/pages/settings.vue
··· 1 1 <script setup lang="ts"> 2 + const router = useRouter() 2 3 const { settings } = useSettings() 3 4 const { locale, locales, setLocale } = useI18n() 4 5 const colorMode = useColorMode() ··· 7 8 locales.value.map(l => (typeof l === 'string' ? { code: l, name: l } : l)), 8 9 ) 9 10 11 + function goBack() { 12 + router.back() 13 + } 14 + 10 15 useSeoMeta({ 11 16 title: 'Settings - npmx', 12 17 }) ··· 14 19 15 20 <template> 16 21 <main class="container py-8 sm:py-12 w-full"> 22 + <!-- Back button --> 23 + <button 24 + type="button" 25 + class="inline-flex items-center gap-2 mb-6 text-sm text-fg-muted hover:text-fg transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded" 26 + @click="goBack" 27 + > 28 + <span class="i-carbon-arrow-left w-4 h-4" aria-hidden="true" /> 29 + {{ $t('nav.back') }} 30 + </button> 31 + 17 32 <div class="space-y-1 p-4 rounded-lg bg-bg-muted border border-border"> 18 33 <button 19 34 type="button"
+2 -1
i18n/locales/en.json
··· 40 40 "nav": { 41 41 "popular_packages": "Popular packages", 42 42 "search": "search", 43 - "settings": "settings" 43 + "settings": "settings", 44 + "back": "Back" 44 45 }, 45 46 "settings": { 46 47 "relative_dates": "Relative dates",
+2 -1
i18n/locales/fr.json
··· 40 40 "nav": { 41 41 "popular_packages": "Paquets populaires", 42 42 "search": "recherche", 43 - "settings": "paramètres" 43 + "settings": "paramètres", 44 + "back": "Retour" 44 45 }, 45 46 "settings": { 46 47 "relative_dates": "Dates relatives",
+2 -1
i18n/locales/it.json
··· 33 33 "nav": { 34 34 "popular_packages": "Pacchetti popolari", 35 35 "search": "cerca", 36 - "settings": "impostazioni" 36 + "settings": "impostazioni", 37 + "back": "Indietro" 37 38 }, 38 39 "settings": { 39 40 "relative_dates": "Date relative",
+2 -1
i18n/locales/zh-CN.json
··· 40 40 "nav": { 41 41 "popular_packages": "热门软件包", 42 42 "search": "搜索", 43 - "settings": "设置" 43 + "settings": "设置", 44 + "back": "返回" 44 45 }, 45 46 "settings": { 46 47 "relative_dates": "相对时间",