[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: allow filtering user/team packages

+406 -98
+6 -9
app/components/PackageCard.vue
··· 10 10 showPublisher?: boolean 11 11 prefetch?: boolean 12 12 }>() 13 - 14 - function formatDate(dateStr: string): string { 15 - return new Date(dateStr).toLocaleDateString('en-US', { 16 - year: 'numeric', 17 - month: 'short', 18 - day: 'numeric', 19 - }) 20 - } 21 13 </script> 22 14 23 15 <template> ··· 68 60 <div v-if="result.package.date" class="flex items-center gap-1.5"> 69 61 <dt class="sr-only">Updated</dt> 70 62 <dd> 71 - <time :datetime="result.package.date">{{ formatDate(result.package.date) }}</time> 63 + <NuxtTime 64 + :datetime="result.package.date" 65 + year="numeric" 66 + month="short" 67 + day="numeric" 68 + /> 72 69 </dd> 73 70 </div> 74 71 </dl>
+96
app/components/PackageListControls.vue
··· 1 + <script setup lang="ts"> 2 + export type SortOption = 'downloads' | 'updated' | 'name-asc' | 'name-desc' 3 + 4 + const props = defineProps<{ 5 + /** Current search/filter text */ 6 + filter: string 7 + /** Current sort option */ 8 + sort: SortOption 9 + /** Placeholder text for the search input */ 10 + placeholder?: string 11 + /** Total count of packages (before filtering) */ 12 + totalCount?: number 13 + /** Filtered count of packages */ 14 + filteredCount?: number 15 + }>() 16 + 17 + const emit = defineEmits<{ 18 + 'update:filter': [value: string] 19 + 'update:sort': [value: SortOption] 20 + }>() 21 + 22 + const filterValue = computed({ 23 + get: () => props.filter, 24 + set: value => emit('update:filter', value), 25 + }) 26 + 27 + const sortValue = computed({ 28 + get: () => props.sort, 29 + set: value => emit('update:sort', value), 30 + }) 31 + 32 + const sortOptions = [ 33 + { value: 'downloads', label: 'Most downloaded' }, 34 + { value: 'updated', label: 'Recently updated' }, 35 + { value: 'name-asc', label: 'Name (A-Z)' }, 36 + { value: 'name-desc', label: 'Name (Z-A)' }, 37 + ] as const 38 + 39 + // Show filter count when filtering is active 40 + const showFilteredCount = computed(() => { 41 + return ( 42 + props.filter && 43 + props.filteredCount !== undefined && 44 + props.totalCount !== undefined && 45 + props.filteredCount !== props.totalCount 46 + ) 47 + }) 48 + </script> 49 + 50 + <template> 51 + <div class="flex flex-col sm:flex-row gap-3 mb-6"> 52 + <!-- Filter input --> 53 + <div class="flex-1 relative"> 54 + <label for="package-filter" class="sr-only">Filter packages</label> 55 + <span 56 + class="absolute left-3 top-1/2 -translate-y-1/2 text-fg-subtle pointer-events-none" 57 + aria-hidden="true" 58 + > 59 + <span class="i-carbon-search inline-block w-4 h-4" /> 60 + </span> 61 + <input 62 + id="package-filter" 63 + v-model="filterValue" 64 + type="search" 65 + :placeholder="placeholder ?? 'Filter packages...'" 66 + autocomplete="off" 67 + class="w-full bg-bg-subtle border border-border rounded-lg pl-9 pr-4 py-2 font-mono text-sm text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:(border-border-hover outline-none)" 68 + /> 69 + </div> 70 + 71 + <!-- Sort select --> 72 + <div class="relative shrink-0"> 73 + <label for="package-sort" class="sr-only">Sort packages</label> 74 + <select 75 + id="package-sort" 76 + v-model="sortValue" 77 + class="appearance-none bg-bg-subtle border border-border rounded-lg pl-3 pr-8 py-2 font-mono text-sm text-fg cursor-pointer transition-colors duration-200 focus:(border-border-hover outline-none) hover:border-border-hover" 78 + > 79 + <option v-for="option in sortOptions" :key="option.value" :value="option.value"> 80 + {{ option.label }} 81 + </option> 82 + </select> 83 + <span 84 + class="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle pointer-events-none" 85 + aria-hidden="true" 86 + > 87 + <span class="i-carbon-chevron-down w-4 h-4" /> 88 + </span> 89 + </div> 90 + </div> 91 + 92 + <!-- Filtered count indicator --> 93 + <p v-if="showFilteredCount" class="text-fg-subtle text-xs font-mono mb-4"> 94 + Showing {{ filteredCount }} of {{ totalCount }} packages 95 + </p> 96 + </template>
+110
app/composables/useNpmRegistry.ts
··· 3 3 PackumentVersion, 4 4 SlimPackument, 5 5 NpmSearchResponse, 6 + NpmSearchResult, 6 7 NpmDownloadCount, 8 + NpmPerson, 7 9 } from '#shared/types' 8 10 9 11 const NPM_REGISTRY = 'https://registry.npmjs.org' ··· 216 218 { default: () => lastSearch || emptySearchResponse }, 217 219 ) 218 220 } 221 + 222 + /** 223 + * Fetch all package names in an npm organization 224 + * Uses the /-/org/{org}/package endpoint 225 + */ 226 + async function fetchOrgPackageNames(orgName: string): Promise<string[]> { 227 + const data = await $fetch<Record<string, string>>( 228 + `${NPM_REGISTRY}/-/org/${encodeURIComponent(orgName)}/package`, 229 + ) 230 + return Object.keys(data) 231 + } 232 + 233 + /** 234 + * Minimal packument data needed for package cards 235 + */ 236 + interface MinimalPackument { 237 + 'name': string 238 + 'description'?: string 239 + 'dist-tags': Record<string, string> 240 + 'time': Record<string, string> 241 + 'maintainers'?: NpmPerson[] 242 + } 243 + 244 + /** 245 + * Fetch minimal packument data for a single package 246 + */ 247 + async function fetchMinimalPackument(name: string): Promise<MinimalPackument | null> { 248 + try { 249 + const encoded = encodePackageName(name) 250 + return await $fetch<MinimalPackument>(`${NPM_REGISTRY}/${encoded}`, { 251 + // Only fetch the fields we need using Accept header 252 + // Note: npm registry doesn't support field filtering, so we get full packument 253 + // but we only use what we need 254 + }) 255 + } catch { 256 + // Package might not exist or be private 257 + return null 258 + } 259 + } 260 + 261 + /** 262 + * Convert packument to search result format for display 263 + */ 264 + function packumentToSearchResult(pkg: MinimalPackument): NpmSearchResult { 265 + const latestVersion = pkg['dist-tags'].latest || Object.values(pkg['dist-tags'])[0] || '' 266 + const modified = pkg.time.modified || pkg.time[latestVersion] || '' 267 + 268 + return { 269 + package: { 270 + name: pkg.name, 271 + version: latestVersion, 272 + description: pkg.description, 273 + date: pkg.time[latestVersion] || modified, 274 + links: { 275 + npm: `https://www.npmjs.com/package/${pkg.name}`, 276 + }, 277 + maintainers: pkg.maintainers, 278 + }, 279 + score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } }, 280 + searchScore: 0, 281 + updated: modified, 282 + } 283 + } 284 + 285 + /** 286 + * Fetch all packages for an npm organization 287 + * Returns search-result-like objects for compatibility with PackageList 288 + */ 289 + export function useOrgPackages(orgName: MaybeRefOrGetter<string>) { 290 + return useLazyAsyncData( 291 + () => `org-packages:${toValue(orgName)}`, 292 + async () => { 293 + const org = toValue(orgName) 294 + if (!org) { 295 + return emptySearchResponse 296 + } 297 + 298 + // Get all package names in the org 299 + const packageNames = await fetchOrgPackageNames(org) 300 + 301 + if (packageNames.length === 0) { 302 + return emptySearchResponse 303 + } 304 + 305 + // Fetch packuments in parallel (with concurrency limit) 306 + const concurrency = 10 307 + const results: NpmSearchResult[] = [] 308 + 309 + for (let i = 0; i < packageNames.length; i += concurrency) { 310 + const batch = packageNames.slice(i, i + concurrency) 311 + const packuments = await Promise.all(batch.map(name => fetchMinimalPackument(name))) 312 + 313 + for (const pkg of packuments) { 314 + if (pkg) { 315 + results.push(packumentToSearchResult(pkg)) 316 + } 317 + } 318 + } 319 + 320 + return { 321 + objects: results, 322 + total: results.length, 323 + time: new Date().toISOString(), 324 + } satisfies NpmSearchResponse 325 + }, 326 + { default: () => emptySearchResponse }, 327 + ) 328 + }
+75 -74
app/pages/@[org].vue
··· 14 14 15 15 const { isConnected } = useConnector() 16 16 17 - // Infinite scroll state 18 - const pageSize = 50 19 - const loadedPages = ref(1) 20 - const isLoadingMore = ref(false) 21 - 22 - // Get initial page from URL (for scroll restoration on reload) 23 - const initialPage = computed(() => { 24 - const p = Number.parseInt(route.query.page as string, 10) 25 - return Number.isNaN(p) ? 1 : Math.max(1, p) 26 - }) 27 - 28 - // Debounced URL update for page 29 - const updateUrlPage = debounce((page: number) => { 17 + // Debounced URL update for filter/sort 18 + const updateUrl = debounce((updates: { filter?: string; sort?: string }) => { 30 19 router.replace({ 31 20 query: { 32 21 ...route.query, 33 - page: page > 1 ? page : undefined, 22 + q: updates.filter || undefined, 23 + sort: updates.sort && updates.sort !== 'downloads' ? updates.sort : undefined, 34 24 }, 35 25 }) 36 - }, 500) 26 + }, 300) 37 27 38 - // Search for packages in this org's scope (@orgname/*) 39 - const searchQuery = computed(() => `@${orgName.value}`) 28 + type SortOption = 'downloads' | 'updated' | 'name-asc' | 'name-desc' 40 29 41 - const { 42 - data: results, 43 - status, 44 - error, 45 - } = useNpmSearch(searchQuery, () => ({ 46 - size: pageSize * loadedPages.value, 47 - })) 30 + // Filter and sort state (from URL) 31 + const filterText = ref((route.query.q as string) ?? '') 32 + const sortOption = ref<SortOption>((route.query.sort as SortOption) || 'downloads') 48 33 49 - // Initialize loaded pages from URL on mount 50 - onMounted(() => { 51 - if (initialPage.value > 1) { 52 - loadedPages.value = initialPage.value 53 - } 34 + // Update URL when filter/sort changes (debounced) 35 + watch([filterText, sortOption], ([filter, sort]) => { 36 + updateUrl({ filter, sort }) 54 37 }) 55 38 56 - // Filter to only include packages that are actually in this scope 57 - // (search may return packages that just mention the org name) 58 - const scopedPackages = computed(() => { 59 - if (!results.value?.objects) return [] 60 - const scopePrefix = `@${orgName.value}/` 61 - return results.value.objects 62 - .filter(obj => obj.package.name.startsWith(scopePrefix)) 63 - .sort((a, b) => b.searchScore - a.searchScore) 64 - }) 39 + // Fetch all packages in this org using the org packages API 40 + const { data: results, status, error } = useOrgPackages(orgName) 65 41 66 - const packageCount = computed(() => scopedPackages.value.length) 42 + const packages = computed(() => results.value?.objects ?? []) 43 + const packageCount = computed(() => packages.value.length) 67 44 68 - // Check if there are potentially more results 69 - const hasMore = computed(() => { 70 - if (!results.value) return false 71 - // npm search API returns max 250 results, but we paginate for faster initial load 72 - return ( 73 - results.value.objects.length >= pageSize * loadedPages.value && 74 - loadedPages.value * pageSize < 250 75 - ) 76 - }) 45 + // Apply client-side filter and sort 46 + const filteredAndSortedPackages = computed(() => { 47 + let pkgs = [...packages.value] 77 48 78 - function loadMore() { 79 - if (isLoadingMore.value || !hasMore.value) return 49 + // Apply text filter 50 + if (filterText.value) { 51 + const search = filterText.value.toLowerCase() 52 + pkgs = pkgs.filter( 53 + pkg => 54 + pkg.package.name.toLowerCase().includes(search) || 55 + pkg.package.description?.toLowerCase().includes(search), 56 + ) 57 + } 80 58 81 - isLoadingMore.value = true 82 - loadedPages.value++ 59 + // Apply sort 60 + switch (sortOption.value) { 61 + case 'updated': 62 + pkgs.sort((a, b) => { 63 + const dateA = a.updated || a.package.date || '' 64 + const dateB = b.updated || b.package.date || '' 65 + return dateB.localeCompare(dateA) 66 + }) 67 + break 68 + case 'name-asc': 69 + pkgs.sort((a, b) => a.package.name.localeCompare(b.package.name)) 70 + break 71 + case 'name-desc': 72 + pkgs.sort((a, b) => b.package.name.localeCompare(a.package.name)) 73 + break 74 + case 'downloads': 75 + default: 76 + pkgs.sort((a, b) => (b.downloads?.weekly ?? 0) - (a.downloads?.weekly ?? 0)) 77 + break 78 + } 83 79 84 - nextTick(() => { 85 - isLoadingMore.value = false 86 - }) 87 - } 80 + return pkgs 81 + }) 88 82 89 - // Update URL when page changes from scrolling 90 - function handlePageChange(page: number) { 91 - updateUrlPage(page) 92 - } 83 + const filteredCount = computed(() => filteredAndSortedPackages.value.length) 93 84 94 - // Reset pagination when org changes 85 + // Reset state when org changes 95 86 watch(orgName, () => { 96 - loadedPages.value = 1 87 + filterText.value = '' 88 + sortOption.value = 'downloads' 97 89 }) 98 90 99 91 const activeTab = ref<'members' | 'teams'>('members') ··· 112 104 113 105 defineOgImageComponent('Default', { 114 106 title: () => `@${orgName.value}`, 115 - description: () => 116 - scopedPackages.value.length ? `${scopedPackages.value.length} packages` : 'npm organization', 107 + description: () => (packageCount.value ? `${packageCount.value} packages` : 'npm organization'), 117 108 }) 118 109 </script> 119 110 ··· 191 182 </ClientOnly> 192 183 193 184 <!-- Loading state --> 194 - <LoadingSpinner v-if="status === 'pending' && loadedPages === 1" text="Loading packages..." /> 185 + <LoadingSpinner v-if="status === 'pending'" text="Loading packages..." /> 195 186 196 187 <!-- Error state --> 197 188 <div v-else-if="status === 'error'" role="alert" class="py-12 text-center"> ··· 212 203 </div> 213 204 214 205 <!-- Package list --> 215 - <section v-else-if="scopedPackages.length > 0" aria-label="Organization packages"> 206 + <section v-else-if="packages.length > 0" aria-label="Organization packages"> 216 207 <h2 class="text-xs text-fg-subtle uppercase tracking-wider mb-4">Packages</h2> 217 208 218 - <PackageList 219 - :results="scopedPackages" 220 - :has-more="hasMore" 221 - :is-loading="isLoadingMore || (status === 'pending' && loadedPages > 1)" 222 - :page-size="pageSize" 223 - :initial-page="initialPage" 224 - @load-more="loadMore" 225 - @page-change="handlePageChange" 209 + <!-- Filter and sort controls --> 210 + <PackageListControls 211 + v-model:filter="filterText" 212 + v-model:sort="sortOption" 213 + :placeholder="`Filter ${packageCount} packages...`" 214 + :total-count="packageCount" 215 + :filtered-count="filteredCount" 226 216 /> 217 + 218 + <!-- No results after filtering --> 219 + <p 220 + v-if="filteredAndSortedPackages.length === 0" 221 + class="text-fg-muted py-8 text-center font-mono" 222 + > 223 + No packages match "<span class="text-fg">{{ filterText }}</span 224 + >" 225 + </p> 226 + 227 + <PackageList v-else :results="filteredAndSortedPackages" /> 227 228 </section> 228 229 </main> 229 230 </template>
+109 -14
app/pages/~[username].vue
··· 9 9 10 10 // Infinite scroll state 11 11 const pageSize = 50 12 + const maxResults = 250 // npm API hard limit 12 13 const loadedPages = ref(1) 13 14 const isLoadingMore = ref(false) 14 15 ··· 18 19 return Number.isNaN(p) ? 1 : Math.max(1, p) 19 20 }) 20 21 21 - // Debounced URL update for page 22 - const updateUrlPage = debounce((page: number) => { 22 + // Debounced URL update for page and filter/sort 23 + const updateUrl = debounce((updates: { page?: number; filter?: string; sort?: string }) => { 23 24 router.replace({ 24 25 query: { 25 26 ...route.query, 26 - page: page > 1 ? page : undefined, 27 + page: updates.page && updates.page > 1 ? updates.page : undefined, 28 + q: updates.filter || undefined, 29 + sort: updates.sort && updates.sort !== 'downloads' ? updates.sort : undefined, 27 30 }, 28 31 }) 29 - }, 500) 32 + }, 300) 33 + 34 + type SortOption = 'downloads' | 'updated' | 'name-asc' | 'name-desc' 35 + 36 + // Filter and sort state (from URL) 37 + const filterText = ref((route.query.q as string) ?? '') 38 + const sortOption = ref<SortOption>((route.query.sort as SortOption) || 'downloads') 39 + 40 + // Track if we've loaded all results (one-way flag, doesn't reset) 41 + // Initialize to true if URL already has filter/sort params 42 + const hasLoadedAll = ref( 43 + Boolean(route.query.q) || (route.query.sort && route.query.sort !== 'downloads'), 44 + ) 45 + 46 + // Update URL when filter/sort changes (debounced) 47 + const debouncedUpdateUrl = debounce((filter: string, sort: string) => { 48 + updateUrl({ filter, sort }) 49 + }, 300) 50 + 51 + watch([filterText, sortOption], ([filter, sort]) => { 52 + // Once user interacts with filter/sort, load all results 53 + if (!hasLoadedAll.value && (filter !== '' || sort !== 'downloads')) { 54 + hasLoadedAll.value = true 55 + } 56 + debouncedUpdateUrl(filter, sort) 57 + }) 30 58 31 59 // Search for packages by this maintainer 32 60 const searchQuery = computed(() => `maintainer:${username.value}`) 61 + 62 + // Request size: load all if user has interacted with filter/sort, otherwise paginate 63 + const requestSize = computed(() => (hasLoadedAll.value ? maxResults : pageSize * loadedPages.value)) 33 64 34 65 const { 35 66 data: results, 36 67 status, 37 68 error, 38 69 } = useNpmSearch(searchQuery, () => ({ 39 - size: pageSize * loadedPages.value, 70 + size: requestSize.value, 40 71 })) 41 72 42 73 // Initialize loaded pages from URL on mount ··· 46 77 } 47 78 }) 48 79 49 - // Sort packages by downloads/popularity (searchScore is a good proxy) 50 - const sortedPackages = computed(() => { 51 - if (!results.value?.objects) return [] 52 - return [...results.value.objects].sort((a, b) => b.searchScore - a.searchScore) 80 + // Get the base packages list 81 + const packages = computed(() => results.value?.objects ?? []) 82 + 83 + const packageCount = computed(() => packages.value.length) 84 + 85 + // Apply client-side filter and sort 86 + const filteredAndSortedPackages = computed(() => { 87 + let pkgs = [...packages.value] 88 + 89 + // Apply text filter 90 + if (filterText.value) { 91 + const search = filterText.value.toLowerCase() 92 + pkgs = pkgs.filter( 93 + pkg => 94 + pkg.package.name.toLowerCase().includes(search) || 95 + pkg.package.description?.toLowerCase().includes(search), 96 + ) 97 + } 98 + 99 + // Apply sort 100 + switch (sortOption.value) { 101 + case 'updated': 102 + pkgs.sort((a, b) => { 103 + const dateA = a.updated || a.package.date || '' 104 + const dateB = b.updated || b.package.date || '' 105 + return dateB.localeCompare(dateA) 106 + }) 107 + break 108 + case 'name-asc': 109 + pkgs.sort((a, b) => a.package.name.localeCompare(b.package.name)) 110 + break 111 + case 'name-desc': 112 + pkgs.sort((a, b) => b.package.name.localeCompare(a.package.name)) 113 + break 114 + case 'downloads': 115 + default: 116 + pkgs.sort((a, b) => (b.downloads?.weekly ?? 0) - (a.downloads?.weekly ?? 0)) 117 + break 118 + } 119 + 120 + return pkgs 53 121 }) 54 122 123 + const filteredCount = computed(() => filteredAndSortedPackages.value.length) 124 + 55 125 // Check if there are potentially more results 56 126 const hasMore = computed(() => { 57 127 if (!results.value) return false 128 + // Don't show "load more" when we've already loaded all 129 + if (hasLoadedAll.value) return false 130 + 58 131 // npm search API returns max 250 results, but we paginate for faster initial load 59 132 return ( 60 133 results.value.objects.length >= pageSize * loadedPages.value && 61 - loadedPages.value * pageSize < 250 134 + loadedPages.value * pageSize < maxResults 62 135 ) 63 136 }) 64 137 ··· 75 148 76 149 // Update URL when page changes from scrolling 77 150 function handlePageChange(page: number) { 78 - updateUrlPage(page) 151 + updateUrl({ page, filter: filterText.value, sort: sortOption.value }) 79 152 } 80 153 81 - // Reset pagination when username changes 154 + // Reset state when username changes 82 155 watch(username, () => { 83 156 loadedPages.value = 1 157 + filterText.value = '' 158 + sortOption.value = 'downloads' 159 + hasLoadedAll.value = false 84 160 }) 85 161 86 162 useSeoMeta({ ··· 150 226 </div> 151 227 152 228 <!-- Package list --> 153 - <section v-else-if="results && sortedPackages.length > 0" aria-label="User packages"> 229 + <section v-else-if="results && packages.length > 0" aria-label="User packages"> 154 230 <h2 class="text-xs text-fg-subtle uppercase tracking-wider mb-4">Packages</h2> 155 231 232 + <!-- Filter and sort controls --> 233 + <PackageListControls 234 + v-model:filter="filterText" 235 + v-model:sort="sortOption" 236 + :placeholder="`Filter ${packageCount} packages...`" 237 + :total-count="packageCount" 238 + :filtered-count="filteredCount" 239 + /> 240 + 241 + <!-- No results after filtering --> 242 + <p 243 + v-if="filteredAndSortedPackages.length === 0" 244 + class="text-fg-muted py-8 text-center font-mono" 245 + > 246 + No packages match "<span class="text-fg">{{ filterText }}</span 247 + >" 248 + </p> 249 + 156 250 <PackageList 157 - :results="sortedPackages" 251 + v-else 252 + :results="filteredAndSortedPackages" 158 253 :has-more="hasMore" 159 254 :is-loading="isLoadingMore || (status === 'pending' && loadedPages > 1)" 160 255 :page-size="pageSize"
+10 -1
shared/types/npm-registry.ts
··· 76 76 package: NpmSearchPackage 77 77 score: NpmSearchScore 78 78 searchScore: number 79 + /** Download counts (weekly/monthly) */ 80 + downloads?: { 81 + weekly?: number 82 + monthly?: number 83 + } 84 + /** Number of dependents */ 85 + dependents?: string 86 + /** Last updated timestamp (ISO 8601) */ 87 + updated?: string 79 88 flags?: { 80 89 unstable?: boolean 81 - insecure?: boolean 90 + insecure?: number 82 91 } 83 92 } 84 93