[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: display exact matches in search results + org/users (#187)

authored by

Daniel Roe and committed by
GitHub
21ed8479 4232ae21

+571 -69
+28 -2
app/components/PackageCard.vue
··· 1 1 <script setup lang="ts"> 2 - defineProps<{ 2 + const props = defineProps<{ 3 3 /** The search result object containing package data */ 4 4 result: NpmSearchResult 5 5 /** Heading level for the package name (h2 for search, h3 for lists) */ ··· 9 9 prefetch?: boolean 10 10 selected?: boolean 11 11 index?: number 12 + /** Search query for highlighting exact matches */ 13 + searchQuery?: string 12 14 }>() 13 15 16 + /** Check if this package is an exact match for the search query */ 17 + const isExactMatch = computed(() => { 18 + if (!props.searchQuery) return false 19 + const query = props.searchQuery.trim().toLowerCase() 20 + const name = props.result.package.name.toLowerCase() 21 + return query === name 22 + }) 23 + 14 24 const emit = defineEmits<{ 15 25 focus: [index: number] 16 26 }>() ··· 19 29 <template> 20 30 <article 21 31 class="group card-interactive scroll-mt-48 scroll-mb-6 relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50" 22 - :class="{ 'bg-bg-muted border-border-hover': selected }" 32 + :class="{ 33 + 'bg-bg-muted border-border-hover': selected, 34 + 'border-accent/30 bg-accent/5': isExactMatch, 35 + }" 23 36 > 37 + <!-- Glow effect for exact matches --> 38 + <div 39 + v-if="isExactMatch" 40 + class="absolute -inset-px rounded-lg bg-gradient-to-r from-accent/0 via-accent/20 to-accent/0 opacity-100 blur-sm -z-1 pointer-events-none motion-reduce:opacity-50" 41 + aria-hidden="true" 42 + /> 24 43 <div class="mb-2 flex items-baseline justify-between gap-2"> 25 44 <component 26 45 :is="headingLevel ?? 'h3'" ··· 37 56 {{ result.package.name }} 38 57 </NuxtLink> 39 58 </component> 59 + <!-- Exact match badge --> 60 + <span 61 + v-if="isExactMatch" 62 + class="shrink-0 text-xs px-1.5 py-0.5 rounded bg-accent/20 border border-accent/30 text-accent font-mono" 63 + > 64 + {{ $t('search.exact_match') }} 65 + </span> 40 66 <!-- Mobile: version next to package name --> 41 67 <div class="sm:hidden text-fg-subtle flex items-center gap-1.5 shrink-0"> 42 68 <span
+3
app/components/PackageList.vue
··· 20 20 initialPage?: number 21 21 /** Selected result index (for keyboard navigation) */ 22 22 selectedIndex?: number 23 + /** Search query for highlighting exact matches */ 24 + searchQuery?: string 23 25 }>() 24 26 25 27 const emit = defineEmits<{ ··· 111 113 :show-publisher="showPublisher" 112 114 :selected="index === (selectedIndex ?? -1)" 113 115 :index="index" 116 + :search-query="searchQuery" 114 117 class="motion-safe:animate-fade-in motion-safe:animate-fill-both" 115 118 :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" 116 119 @focus="emit('select', $event)"
+85
app/components/SearchSuggestionCard.vue
··· 1 + <script setup lang="ts"> 2 + defineProps<{ 3 + /** Type of suggestion: 'user' or 'org' */ 4 + type: 'user' | 'org' 5 + /** The name (username or org name) */ 6 + name: string 7 + /** Whether this suggestion is currently selected (keyboard nav) */ 8 + selected?: boolean 9 + /** Whether this is an exact match for the query */ 10 + isExactMatch?: boolean 11 + /** Index for keyboard navigation */ 12 + index?: number 13 + }>() 14 + 15 + const emit = defineEmits<{ 16 + focus: [index: number] 17 + }>() 18 + </script> 19 + 20 + <template> 21 + <article 22 + class="group card-interactive relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50" 23 + :class="{ 24 + 'bg-bg-muted border-border-hover': selected, 25 + 'border-accent/30 bg-accent/5': isExactMatch, 26 + }" 27 + > 28 + <!-- Glow effect for exact matches --> 29 + <div 30 + v-if="isExactMatch" 31 + class="absolute -inset-px rounded-lg bg-gradient-to-r from-accent/0 via-accent/20 to-accent/0 opacity-100 blur-sm -z-1 pointer-events-none motion-reduce:opacity-50" 32 + aria-hidden="true" 33 + /> 34 + <NuxtLink 35 + :to="type === 'user' ? `/~${name}` : `/@${name}`" 36 + :data-suggestion-index="index" 37 + class="flex items-center gap-4 focus-visible:outline-none after:content-[''] after:absolute after:inset-0" 38 + @focus="index != null && emit('focus', index)" 39 + @mouseenter="index != null && emit('focus', index)" 40 + > 41 + <!-- Avatar placeholder --> 42 + <div 43 + class="w-10 h-10 shrink-0 flex items-center justify-center border border-border" 44 + :class="type === 'org' ? 'rounded-lg bg-bg-muted' : 'rounded-full bg-bg-muted'" 45 + aria-hidden="true" 46 + > 47 + <span class="text-lg text-fg-subtle font-mono">{{ name.charAt(0).toUpperCase() }}</span> 48 + </div> 49 + 50 + <div class="min-w-0 flex-1"> 51 + <div class="flex items-center gap-2"> 52 + <span 53 + class="font-mono text-sm sm:text-base font-medium text-fg group-hover:text-fg transition-colors" 54 + > 55 + {{ type === 'user' ? '~' : '@' }}{{ name }} 56 + </span> 57 + <span 58 + class="text-xs px-1.5 py-0.5 rounded bg-bg-muted border border-border text-fg-muted font-mono" 59 + > 60 + {{ type === 'user' ? $t('search.suggestion.user') : $t('search.suggestion.org') }} 61 + </span> 62 + <!-- Exact match badge --> 63 + <span 64 + v-if="isExactMatch" 65 + class="text-xs px-1.5 py-0.5 rounded bg-accent/20 border border-accent/30 text-accent font-mono" 66 + > 67 + {{ $t('search.exact_match') }} 68 + </span> 69 + </div> 70 + <p class="text-xs sm:text-sm text-fg-muted mt-0.5"> 71 + {{ 72 + type === 'user' 73 + ? $t('search.suggestion.view_user_packages') 74 + : $t('search.suggestion.view_org_packages') 75 + }} 76 + </p> 77 + </div> 78 + 79 + <span 80 + class="i-carbon-arrow-right w-4 h-4 text-fg-subtle group-hover:text-fg transition-colors shrink-0" 81 + aria-hidden="true" 82 + /> 83 + </NuxtLink> 84 + </article> 85 + </template>
+447 -66
app/pages/search.vue
··· 52 52 53 53 const resultCount = computed(() => visibleResults.value?.objects.length ?? 0) 54 54 55 - function clampIndex(next: number) { 56 - if (resultCount.value <= 0) return 0 57 - return Math.max(0, Math.min(resultCount.value - 1, next)) 58 - } 59 - 60 - function scrollToSelectedResult() { 61 - // Use virtualizer's scrollToIndex to ensure the item is rendered and visible 62 - packageListRef.value?.scrollToIndex(selectedIndex.value) 63 - } 64 - 65 - function focusSelectedResult() { 66 - // First ensure the item is rendered by scrolling to it 67 - scrollToSelectedResult() 68 - // Then focus it after a tick to allow rendering 69 - nextTick(() => { 70 - const el = document.querySelector<HTMLElement>(`[data-result-index="${selectedIndex.value}"]`) 71 - el?.focus() 72 - }) 73 - } 74 - 75 - function handleResultsKeydown(e: KeyboardEvent) { 76 - if (resultCount.value <= 0) return 77 - 78 - const isFromInput = (e.target as HTMLElement).tagName === 'INPUT' 79 - 80 - if (e.key === 'ArrowDown') { 81 - e.preventDefault() 82 - selectedIndex.value = clampIndex(selectedIndex.value + 1) 83 - // Only move focus if already in results, not when typing in search input 84 - if (isFromInput) { 85 - scrollToSelectedResult() 86 - } else { 87 - focusSelectedResult() 88 - } 89 - return 90 - } 91 - 92 - if (e.key === 'ArrowUp') { 93 - e.preventDefault() 94 - selectedIndex.value = clampIndex(selectedIndex.value - 1) 95 - if (isFromInput) { 96 - scrollToSelectedResult() 97 - } else { 98 - focusSelectedResult() 99 - } 100 - return 101 - } 102 - 103 - if (e.key === 'Enter') { 104 - const el = document.querySelector<HTMLElement>(`[data-result-index="${selectedIndex.value}"]`) 105 - if (!el) return 106 - e.preventDefault() 107 - el.click() 108 - } 109 - } 110 - 111 55 // Track if page just loaded (for hiding "Searching..." during view transition) 112 56 const hasInteracted = ref(false) 113 57 onMounted(() => { ··· 165 109 }) 166 110 167 111 // Show cached results while loading if it's a continuation query 168 - const visibleResults = computed(() => { 112 + const rawVisibleResults = computed(() => { 169 113 if (status.value === 'pending' && isQueryContinuation.value && cachedResults.value) { 170 114 return cachedResults.value 171 115 } 172 116 return results.value 173 117 }) 174 118 119 + /** 120 + * Reorder results to put exact package name match at the top 121 + */ 122 + const visibleResults = computed(() => { 123 + const raw = rawVisibleResults.value 124 + if (!raw) return raw 125 + 126 + const q = query.value.trim().toLowerCase() 127 + if (!q) return raw 128 + 129 + // Find exact match index 130 + const exactIdx = raw.objects.findIndex(r => r.package.name.toLowerCase() === q) 131 + if (exactIdx <= 0) return raw // Already at top or not found 132 + 133 + // Move exact match to top 134 + const reordered = [...raw.objects] 135 + const [exactMatch] = reordered.splice(exactIdx, 1) 136 + if (exactMatch) { 137 + reordered.unshift(exactMatch) 138 + } 139 + 140 + return { 141 + ...raw, 142 + objects: reordered, 143 + } 144 + }) 145 + 175 146 // Should we show the loading spinner? 176 147 const showSearching = computed(() => { 177 148 // Don't show during initial page load (view transition) ··· 207 178 // Update URL when page changes from scrolling 208 179 function handlePageChange(page: number) { 209 180 updateUrlPage(page) 210 - } 211 - 212 - function handleSelect(index: number) { 213 - if (index < 0) return 214 - selectedIndex.value = clampIndex(index) 215 181 } 216 182 217 183 // Reset pages when query changes ··· 326 292 // Modal state for claiming a package 327 293 const claimModalOpen = ref(false) 328 294 295 + /** 296 + * Check if a string is a valid npm username/org name 297 + * npm usernames: 1-214 characters, lowercase, alphanumeric, hyphen, underscore 298 + * Must not start with hyphen or underscore 299 + */ 300 + function isValidNpmName(name: string): boolean { 301 + if (!name || name.length === 0 || name.length > 214) return false 302 + // Must start with alphanumeric 303 + if (!/^[a-z0-9]/i.test(name)) return false 304 + // Can contain alphanumeric, hyphen, underscore 305 + return /^[a-z0-9_-]+$/i.test(name) 306 + } 307 + 308 + /** Validated user/org suggestion */ 309 + interface ValidatedSuggestion { 310 + type: 'user' | 'org' 311 + name: string 312 + exists: boolean 313 + } 314 + 315 + /** Cache for existence checks to avoid repeated API calls */ 316 + const existenceCache = ref<Record<string, boolean | 'pending'>>({}) 317 + 318 + const NPM_REGISTRY = 'https://registry.npmjs.org' 319 + 320 + interface NpmSearchResponse { 321 + total: number 322 + objects: Array<{ package: { name: string } }> 323 + } 324 + 325 + /** 326 + * Check if an org exists by searching for packages with @orgname scope 327 + * Uses the search API which has CORS enabled 328 + */ 329 + async function checkOrgExists(name: string): Promise<boolean> { 330 + const cacheKey = `org:${name.toLowerCase()}` 331 + if (cacheKey in existenceCache.value) { 332 + const cached = existenceCache.value[cacheKey] 333 + return cached === true 334 + } 335 + existenceCache.value[cacheKey] = 'pending' 336 + try { 337 + // Search for packages in the @org scope 338 + const response = await $fetch<NpmSearchResponse>(`${NPM_REGISTRY}/-/v1/search`, { 339 + query: { text: `@${name}`, size: 5 }, 340 + }) 341 + // Verify at least one result actually starts with @orgname/ 342 + const scopePrefix = `@${name.toLowerCase()}/` 343 + const exists = response.objects.some(obj => 344 + obj.package.name.toLowerCase().startsWith(scopePrefix), 345 + ) 346 + existenceCache.value[cacheKey] = exists 347 + return exists 348 + } catch { 349 + existenceCache.value[cacheKey] = false 350 + return false 351 + } 352 + } 353 + 354 + /** 355 + * Check if a user exists by searching for packages they maintain 356 + * Uses the search API which has CORS enabled 357 + */ 358 + async function checkUserExists(name: string): Promise<boolean> { 359 + const cacheKey = `user:${name.toLowerCase()}` 360 + if (cacheKey in existenceCache.value) { 361 + const cached = existenceCache.value[cacheKey] 362 + return cached === true 363 + } 364 + existenceCache.value[cacheKey] = 'pending' 365 + try { 366 + const response = await $fetch<{ total: number }>(`${NPM_REGISTRY}/-/v1/search`, { 367 + query: { text: `maintainer:${name}`, size: 1 }, 368 + }) 369 + const exists = response.total > 0 370 + existenceCache.value[cacheKey] = exists 371 + return exists 372 + } catch { 373 + existenceCache.value[cacheKey] = false 374 + return false 375 + } 376 + } 377 + 378 + /** 379 + * Parse the search query to extract potential user/org name 380 + */ 381 + interface ParsedQuery { 382 + type: 'user' | 'org' | 'both' | null 383 + name: string 384 + } 385 + 386 + const parsedQuery = computed<ParsedQuery>(() => { 387 + const q = query.value.trim() 388 + if (!q) return { type: null, name: '' } 389 + 390 + // Query starts with ~ - explicit user search 391 + if (q.startsWith('~')) { 392 + const name = q.slice(1) 393 + if (isValidNpmName(name)) { 394 + return { type: 'user', name } 395 + } 396 + return { type: null, name: '' } 397 + } 398 + 399 + // Query starts with @ - org search (without slash) 400 + if (q.startsWith('@')) { 401 + // If it contains a slash, it's a scoped package search 402 + if (q.includes('/')) return { type: null, name: '' } 403 + const name = q.slice(1) 404 + if (isValidNpmName(name)) { 405 + return { type: 'org', name } 406 + } 407 + return { type: null, name: '' } 408 + } 409 + 410 + // Plain query - could be user, org, or package 411 + if (isValidNpmName(q)) { 412 + return { type: 'both', name: q } 413 + } 414 + 415 + return { type: null, name: '' } 416 + }) 417 + 418 + /** Validated suggestions (only those that exist) */ 419 + const validatedSuggestions = ref<ValidatedSuggestion[]>([]) 420 + const suggestionsLoading = ref(false) 421 + 422 + /** Debounced function to validate suggestions */ 423 + const validateSuggestions = debounce(async (parsed: ParsedQuery) => { 424 + if (!parsed.type || !parsed.name) { 425 + validatedSuggestions.value = [] 426 + return 427 + } 428 + 429 + suggestionsLoading.value = true 430 + const suggestions: ValidatedSuggestion[] = [] 431 + 432 + try { 433 + if (parsed.type === 'user') { 434 + const exists = await checkUserExists(parsed.name) 435 + if (exists) { 436 + suggestions.push({ type: 'user', name: parsed.name, exists: true }) 437 + } 438 + } else if (parsed.type === 'org') { 439 + const exists = await checkOrgExists(parsed.name) 440 + if (exists) { 441 + suggestions.push({ type: 'org', name: parsed.name, exists: true }) 442 + } 443 + } else if (parsed.type === 'both') { 444 + // Check both in parallel 445 + const [orgExists, userExists] = await Promise.all([ 446 + checkOrgExists(parsed.name), 447 + checkUserExists(parsed.name), 448 + ]) 449 + // Org first (more common) 450 + if (orgExists) { 451 + suggestions.push({ type: 'org', name: parsed.name, exists: true }) 452 + } 453 + if (userExists) { 454 + suggestions.push({ type: 'user', name: parsed.name, exists: true }) 455 + } 456 + } 457 + } finally { 458 + suggestionsLoading.value = false 459 + } 460 + 461 + validatedSuggestions.value = suggestions 462 + }, 200) 463 + 464 + // Validate suggestions when query changes 465 + watch( 466 + parsedQuery, 467 + parsed => { 468 + validateSuggestions(parsed) 469 + }, 470 + { immediate: true }, 471 + ) 472 + 473 + /** Check if there's an exact package match in results */ 474 + const hasExactPackageMatch = computed(() => { 475 + const q = query.value.trim().toLowerCase() 476 + if (!q || !visibleResults.value) return false 477 + return visibleResults.value.objects.some(r => r.package.name.toLowerCase() === q) 478 + }) 479 + 480 + /** Check if query is an exact org match (e.g., @nuxt matches org nuxt) */ 481 + const isExactOrgQuery = computed(() => { 482 + const q = query.value.trim() 483 + if (!q.startsWith('@') || q.includes('/')) return false 484 + const orgName = q.slice(1).toLowerCase() 485 + return validatedSuggestions.value.some( 486 + s => s.type === 'org' && s.name.toLowerCase() === orgName && s.exists, 487 + ) 488 + }) 489 + 490 + /** Determine which item should be highlighted as exact match */ 491 + const exactMatchType = computed<'package' | 'org' | 'user' | null>(() => { 492 + // Package match takes priority 493 + if (hasExactPackageMatch.value) return 'package' 494 + // Then org match for @org queries 495 + if (isExactOrgQuery.value) return 'org' 496 + // Could extend to user matches for ~user queries 497 + const q = query.value.trim() 498 + if (q.startsWith('~')) { 499 + const userName = q.slice(1).toLowerCase() 500 + if ( 501 + validatedSuggestions.value.some( 502 + s => s.type === 'user' && s.name.toLowerCase() === userName && s.exists, 503 + ) 504 + ) { 505 + return 'user' 506 + } 507 + } 508 + return null 509 + }) 510 + 511 + /** 512 + * Selection uses negative indices for suggestions, positive for packages 513 + * -2 = first suggestion, -1 = second suggestion, 0+ = package indices 514 + */ 515 + const suggestionCount = computed(() => validatedSuggestions.value.length) 516 + const totalSelectableCount = computed(() => suggestionCount.value + resultCount.value) 517 + 518 + /** Unified selected index: negative for suggestions, 0+ for packages */ 519 + const unifiedSelectedIndex = ref(0) 520 + 521 + /** Convert unified index to suggestion index (0-based) or null */ 522 + function toSuggestionIndex(unified: number): number | null { 523 + if (unified < 0 && unified >= -suggestionCount.value) { 524 + return suggestionCount.value + unified 525 + } 526 + return null 527 + } 528 + 529 + /** Convert unified index to package index or null */ 530 + function toPackageIndex(unified: number): number | null { 531 + if (unified >= 0 && unified < resultCount.value) { 532 + return unified 533 + } 534 + return null 535 + } 536 + 537 + /** Clamp unified index to valid range */ 538 + function clampUnifiedIndex(next: number): number { 539 + const min = -suggestionCount.value 540 + const max = Math.max(0, resultCount.value - 1) 541 + if (totalSelectableCount.value <= 0) return 0 542 + return Math.max(min, Math.min(max, next)) 543 + } 544 + 545 + // Keep legacy selectedIndex in sync for PackageList 546 + watch(unifiedSelectedIndex, unified => { 547 + const pkgIndex = toPackageIndex(unified) 548 + selectedIndex.value = pkgIndex ?? -1 549 + }) 550 + 551 + // Initialize selection to exact match when results load 552 + watch( 553 + [visibleResults, validatedSuggestions, exactMatchType], 554 + () => { 555 + if (exactMatchType.value === 'package') { 556 + // Find the exact match package index 557 + const q = query.value.trim().toLowerCase() 558 + const idx = 559 + visibleResults.value?.objects.findIndex(r => r.package.name.toLowerCase() === q) ?? -1 560 + if (idx >= 0) { 561 + unifiedSelectedIndex.value = idx 562 + return 563 + } 564 + } 565 + if (exactMatchType.value === 'org') { 566 + // Select the org suggestion 567 + const orgIdx = validatedSuggestions.value.findIndex(s => s.type === 'org') 568 + if (orgIdx >= 0) { 569 + unifiedSelectedIndex.value = -(suggestionCount.value - orgIdx) 570 + return 571 + } 572 + } 573 + if (exactMatchType.value === 'user') { 574 + // Select the user suggestion 575 + const userIdx = validatedSuggestions.value.findIndex(s => s.type === 'user') 576 + if (userIdx >= 0) { 577 + unifiedSelectedIndex.value = -(suggestionCount.value - userIdx) 578 + return 579 + } 580 + } 581 + // Default to first item (first suggestion if any, else first package) 582 + unifiedSelectedIndex.value = suggestionCount.value > 0 ? -suggestionCount.value : 0 583 + }, 584 + { immediate: true }, 585 + ) 586 + 587 + // Reset selection when query changes 588 + watch(query, () => { 589 + // Will be re-initialized by the watch above when results load 590 + unifiedSelectedIndex.value = 0 591 + }) 592 + 593 + function scrollToSelectedItem() { 594 + const pkgIndex = toPackageIndex(unifiedSelectedIndex.value) 595 + if (pkgIndex !== null) { 596 + packageListRef.value?.scrollToIndex(pkgIndex) 597 + } 598 + } 599 + 600 + function focusSelectedItem() { 601 + const suggIdx = toSuggestionIndex(unifiedSelectedIndex.value) 602 + const pkgIdx = toPackageIndex(unifiedSelectedIndex.value) 603 + 604 + nextTick(() => { 605 + if (suggIdx !== null) { 606 + const el = document.querySelector<HTMLElement>(`[data-suggestion-index="${suggIdx}"]`) 607 + el?.focus() 608 + } else if (pkgIdx !== null) { 609 + scrollToSelectedItem() 610 + nextTick(() => { 611 + const el = document.querySelector<HTMLElement>(`[data-result-index="${pkgIdx}"]`) 612 + el?.focus() 613 + }) 614 + } 615 + }) 616 + } 617 + 618 + function handleResultsKeydown(e: KeyboardEvent) { 619 + if (totalSelectableCount.value <= 0) return 620 + 621 + const isFromInput = (e.target as HTMLElement).tagName === 'INPUT' 622 + 623 + if (e.key === 'ArrowDown') { 624 + e.preventDefault() 625 + unifiedSelectedIndex.value = clampUnifiedIndex(unifiedSelectedIndex.value + 1) 626 + if (isFromInput) { 627 + scrollToSelectedItem() 628 + } else { 629 + focusSelectedItem() 630 + } 631 + return 632 + } 633 + 634 + if (e.key === 'ArrowUp') { 635 + e.preventDefault() 636 + unifiedSelectedIndex.value = clampUnifiedIndex(unifiedSelectedIndex.value - 1) 637 + if (isFromInput) { 638 + scrollToSelectedItem() 639 + } else { 640 + focusSelectedItem() 641 + } 642 + return 643 + } 644 + 645 + if (e.key === 'Enter') { 646 + const suggIdx = toSuggestionIndex(unifiedSelectedIndex.value) 647 + const pkgIdx = toPackageIndex(unifiedSelectedIndex.value) 648 + 649 + if (suggIdx !== null) { 650 + const el = document.querySelector<HTMLElement>(`[data-suggestion-index="${suggIdx}"]`) 651 + if (el) { 652 + e.preventDefault() 653 + el.click() 654 + } 655 + } else if (pkgIdx !== null) { 656 + const el = document.querySelector<HTMLElement>(`[data-result-index="${pkgIdx}"]`) 657 + if (el) { 658 + e.preventDefault() 659 + el.click() 660 + } 661 + } 662 + } 663 + } 664 + 665 + function handleSuggestionSelect(index: number) { 666 + // Convert suggestion index to unified index 667 + unifiedSelectedIndex.value = -(suggestionCount.value - index) 668 + } 669 + 670 + function handlePackageSelect(index: number) { 671 + if (index < 0) return 672 + unifiedSelectedIndex.value = index 673 + } 674 + 329 675 useSeoMeta({ 330 676 title: () => (query.value ? `Search: ${query.value} - npmx` : 'Search Packages - npmx'), 331 677 }) ··· 401 747 <LoadingSpinner v-if="showSearching" :text="$t('search.searching')" /> 402 748 403 749 <div v-else-if="visibleResults"> 750 + <!-- User/Org search suggestions --> 751 + <div v-if="validatedSuggestions.length > 0" class="mb-6 space-y-3"> 752 + <SearchSuggestionCard 753 + v-for="(suggestion, idx) in validatedSuggestions" 754 + :key="`${suggestion.type}-${suggestion.name}`" 755 + :type="suggestion.type" 756 + :name="suggestion.name" 757 + :index="idx" 758 + :selected="toSuggestionIndex(unifiedSelectedIndex) === idx" 759 + :is-exact-match=" 760 + (exactMatchType === 'org' && suggestion.type === 'org') || 761 + (exactMatchType === 'user' && suggestion.type === 'user') 762 + " 763 + @focus="handleSuggestionSelect" 764 + /> 765 + </div> 766 + 404 767 <!-- Claim prompt - shown at top when valid name but no exact match --> 405 768 <div 406 769 v-if="showClaimPrompt && visibleResults.total > 0" ··· 433 796 </p> 434 797 435 798 <!-- No results found --> 436 - <div v-else-if="status !== 'pending'" role="status" class="py-12 text-center"> 437 - <p class="text-fg-muted font-mono mb-6"> 799 + <div v-else-if="status !== 'pending'" role="status" class="py-12"> 800 + <p class="text-fg-muted font-mono mb-6 text-center"> 438 801 {{ $t('search.no_results', { query }) }} 439 802 </p> 440 803 804 + <!-- User/Org suggestions when no packages found --> 805 + <div v-if="validatedSuggestions.length > 0" class="max-w-md mx-auto mb-6 space-y-3"> 806 + <SearchSuggestionCard 807 + v-for="(suggestion, idx) in validatedSuggestions" 808 + :key="`${suggestion.type}-${suggestion.name}`" 809 + :type="suggestion.type" 810 + :name="suggestion.name" 811 + :index="idx" 812 + :selected="toSuggestionIndex(unifiedSelectedIndex) === idx" 813 + :is-exact-match=" 814 + (exactMatchType === 'org' && suggestion.type === 'org') || 815 + (exactMatchType === 'user' && suggestion.type === 'user') 816 + " 817 + @focus="handleSuggestionSelect" 818 + /> 819 + </div> 820 + 441 821 <!-- Offer to claim the package name if it's valid --> 442 - <div v-if="showClaimPrompt" class="max-w-md mx-auto"> 822 + <div v-if="showClaimPrompt" class="max-w-md mx-auto text-center"> 443 823 <div class="p-4 bg-bg-subtle border border-border rounded-lg"> 444 824 <p class="text-sm text-fg-muted mb-3">{{ $t('search.want_to_claim') }}</p> 445 825 <button ··· 458 838 ref="packageListRef" 459 839 :results="visibleResults.objects" 460 840 :selected-index="selectedIndex" 841 + :search-query="query" 461 842 heading-level="h2" 462 843 show-publisher 463 844 :has-more="hasMore" ··· 466 847 :initial-page="initialPage" 467 848 @load-more="loadMore" 468 849 @page-change="handlePageChange" 469 - @select="handleSelect" 850 + @select="handlePackageSelect" 470 851 /> 471 852 </div> 472 853 </section>
+8 -1
i18n/locales/en.json
··· 26 26 "claim_prompt": "Claim this package name on npm", 27 27 "claim_button": "Claim \"{name}\"", 28 28 "want_to_claim": "Want to claim this package name?", 29 - "start_typing": "Start typing to search packages" 29 + "start_typing": "Start typing to search packages", 30 + "exact_match": "exact", 31 + "suggestion": { 32 + "user": "user", 33 + "org": "org", 34 + "view_user_packages": "View packages by this user", 35 + "view_org_packages": "View packages by this organization" 36 + } 30 37 }, 31 38 "nav": { 32 39 "popular_packages": "Popular packages",