11<script setup lang="ts">
2- import type { FilterChip } from ' #shared/types/preferences'
2+ import type { FilterChip , SortKey } from ' #shared/types/preferences'
3+ import { parseSortOption } from ' #shared/types/preferences'
34import { onKeyDown } from ' @vueuse/core'
45import { debounce } from ' perfect-debounce'
56import { isValidNewPackageName , checkPackageExists } from ' ~/utils/package-name'
@@ -9,6 +10,10 @@ import { normalizeSearchParam } from '#shared/utils/url'
910const route = useRoute ()
1011const router = useRouter ()
1112
13+ // Search provider
14+ const { search : algoliaSearch } = useAlgoliaSearch ()
15+ const { isAlgolia } = useSearchProvider ()
16+
1217// Preferences (persisted to localStorage)
1318const {
1419 viewMode,
@@ -45,13 +50,6 @@ onMounted(() => {
4550const pageSize = 25
4651const currentPage = shallowRef (1 )
4752
48- // Calculate how many results we need based on current page and preferred page size
49- const requestedSize = computed (() => {
50- const numericPrefSize = preferredPageSize .value === ' all' ? 250 : preferredPageSize .value
51- // Always fetch at least enough for the current page
52- return Math .max (pageSize , currentPage .value * numericPrefSize )
53- })
54-
5553// Get initial page from URL (for scroll restoration on reload)
5654const initialPage = computed (() => {
5755 const p = Number .parseInt (normalizeSearchParam (route .query .page ), 10 )
@@ -65,18 +63,6 @@ onMounted(() => {
6563 }
6664})
6765
68- // Use incremental search with client-side caching
69- const {
70- data : results,
71- status,
72- isLoadingMore,
73- hasMore,
74- fetchMore,
75- isRateLimited,
76- } = useNpmSearch (query , () => ({
77- size: requestedSize .value ,
78- }))
79-
8066// Results to display (directly from incremental search)
8167const rawVisibleResults = computed (() => results .value )
8268
@@ -125,11 +111,27 @@ const visibleResults = computed(() => {
125111// Use structured filters for client-side refinement of search results
126112const resultsArray = computed (() => visibleResults .value ?.objects ?? [])
127113
114+ // Sort keys that the npm registry path doesn't support (only relevance works server-side)
115+ const NON_RELEVANCE_SORT_KEYS: SortKey [] = [
116+ ' downloads-week' ,
117+ ' downloads-day' ,
118+ ' downloads-month' ,
119+ ' downloads-year' ,
120+ ' updated' ,
121+ ' name' ,
122+ ' quality' ,
123+ ' popularity' ,
124+ ' maintenance' ,
125+ ' score' ,
126+ ]
127+
128+ // Disable non-relevance sorts when using npm provider (results are relevance-only from server)
129+ const disabledSortKeys = computed <SortKey []>(() => (isAlgolia .value ? [] : NON_RELEVANCE_SORT_KEYS ))
130+
128131// Minimal structured filters usage for search context (no client-side filtering)
129132const {
130133 filters,
131134 sortOption,
132- sortedPackages,
133135 availableKeywords,
134136 activeFilters,
135137 setTextFilter,
@@ -148,19 +150,90 @@ const {
148150 initialSort: ' relevance-desc' , // Default to search relevance
149151})
150152
151- // Client-side filtered/sorted results for display
152- // In search context, we always use server order (relevance) - no client-side filtering
153+ const isRelevanceSort = computed (
154+ () => sortOption .value === ' relevance-desc' || sortOption .value === ' relevance-asc' ,
155+ )
156+
157+ // Calculate how many results we need based on current page and preferred page size
158+ const requestedSize = computed (() => {
159+ const numericPrefSize = preferredPageSize .value === ' all' ? 250 : preferredPageSize .value
160+ const base = Math .max (pageSize , currentPage .value * numericPrefSize )
161+ // When sorting by something other than relevance, fetch a large batch from Algolia
162+ // so client-side sorting operates on a meaningful pool of matching results
163+ if (! isRelevanceSort .value ) {
164+ return Math .max (base , 250 )
165+ }
166+ return base
167+ })
168+
169+ // Reset to relevance sort when switching to npm (which only supports relevance)
170+ watch (isAlgolia , algolia => {
171+ if (! algolia && ! isRelevanceSort .value ) {
172+ sortOption .value = ' relevance-desc'
173+ }
174+ })
175+
176+ // Use incremental search with client-side caching
177+ const {
178+ data : results,
179+ status,
180+ isLoadingMore,
181+ hasMore,
182+ fetchMore,
183+ isRateLimited,
184+ } = useNpmSearch (query , () => ({
185+ size: requestedSize .value ,
186+ }))
187+
188+ // Client-side sorted results for display
189+ // The search API already handles text filtering, so we only need to sort.
153190const displayResults = computed (() => {
154- // When using relevance sort, return original server-sorted results
155- if (sortOption .value === ' relevance-desc' || sortOption .value === ' relevance-asc' ) {
191+ if (isRelevanceSort .value ) {
156192 return resultsArray .value
157193 }
158194
159- return sortedPackages .value
195+ // Sort the fetched results client-side (Algolia doesn't support arbitrary
196+ // sort orders without replica indices, so we fetch a large batch and sort here)
197+ const { key, direction } = parseSortOption (sortOption .value )
198+ const multiplier = direction === ' asc' ? 1 : - 1
199+
200+ return [... resultsArray .value ].sort ((a , b ) => {
201+ let diff: number
202+ switch (key ) {
203+ case ' downloads-week' :
204+ case ' downloads-day' :
205+ case ' downloads-month' :
206+ case ' downloads-year' :
207+ diff = (a .downloads ?.weekly ?? 0 ) - (b .downloads ?.weekly ?? 0 )
208+ break
209+ case ' updated' :
210+ diff = new Date (a .package .date ).getTime () - new Date (b .package .date ).getTime ()
211+ break
212+ case ' name' :
213+ diff = a .package .name .localeCompare (b .package .name )
214+ break
215+ default :
216+ diff = 0
217+ }
218+ return diff * multiplier
219+ })
160220})
161221
162222const resultCount = computed (() => displayResults .value .length )
163223
224+ /**
225+ * The effective total for display and pagination purposes.
226+ * When sorting by non-relevance, we're working with a fetched subset (e.g. 250),
227+ * not the full Algolia total (e.g. 92,324). Show the actual working set size.
228+ */
229+ const effectiveTotal = computed (() => {
230+ if (isRelevanceSort .value ) {
231+ return visibleResults .value ?.total ?? 0
232+ }
233+ // When sorting, the total is the number of results we actually fetched and sorted
234+ return displayResults .value .length
235+ })
236+
164237// Handle filter chip removal
165238function handleClearFilter(chip : FilterChip ) {
166239 clearFilter (chip )
@@ -316,9 +389,6 @@ interface ValidatedSuggestion {
316389/** Cache for existence checks to avoid repeated API calls */
317390const existenceCache = ref <Record <string , boolean | ' pending' >>({})
318391
319- const { search : algoliaSearch } = useAlgoliaSearch ()
320- const { isAlgolia } = useSearchProvider ()
321-
322392/**
323393 * Check if an org exists by searching for scoped packages (@orgname/...).
324394 * When Algolia is active, searches for `@name /` scoped packages via text query.
@@ -773,10 +843,11 @@ defineOgImageComponent('Default', {
773843 :columns =" columns"
774844 v-model:pagination-mode =" paginationMode"
775845 v-model:page-size =" preferredPageSize"
776- :total-count =" visibleResults.total "
846+ :total-count =" effectiveTotal "
777847 :filtered-count =" displayResults.length"
778848 :available-keywords =" availableKeywords"
779849 :active-filters =" activeFilters"
850+ :disabled-sort-keys =" disabledSortKeys"
780851 search-context
781852 @toggle-column =" toggleColumn"
782853 @reset-columns =" resetColumns"
@@ -789,24 +860,31 @@ defineOgImageComponent('Default', {
789860 @update:updated-within =" setUpdatedWithin"
790861 @toggle-keyword =" toggleKeyword"
791862 />
792- <!-- Show "Found X packages" (infinite scroll mode only) -->
863+ <!-- Show count status (infinite scroll mode only) -->
793864 <p
794865 v-if =" viewMode === 'cards' && paginationMode === 'infinite'"
795866 role =" status"
796867 class =" text-fg-muted text-sm mt-4 font-mono"
797868 >
798- {{
799- $t(
800- 'search.found_packages',
801- { count: $n(visibleResults.total) },
802- visibleResults.total,
803- )
804- }}
869+ <template v-if =" isRelevanceSort " >
870+ {{
871+ $t(
872+ 'search.found_packages',
873+ { count: $n(visibleResults.total) },
874+ visibleResults.total,
875+ )
876+ }}
877+ </template >
878+ <template v-else >
879+ {{
880+ $t('search.found_packages_sorted', { count: $n(effectiveTotal) }, effectiveTotal)
881+ }}
882+ </template >
805883 <span v-if =" status === 'pending'" class =" text-fg-subtle" >{{
806884 $t('search.updating')
807885 }}</span >
808886 </p >
809- <!-- Show "x of y packages " (paginated/table mode only) -->
887+ <!-- Show "x of y" (paginated/table mode only) -->
810888 <p
811889 v-if =" viewMode === 'table' || paginationMode === 'paginated'"
812890 role =" status"
@@ -816,11 +894,10 @@ defineOgImageComponent('Default', {
816894 $t(
817895 'filters.count.showing_paginated',
818896 {
819- pageSize:
820- preferredPageSize === 'all' ? $n(visibleResults.total) : preferredPageSize,
821- count: $n(visibleResults.total),
897+ pageSize: preferredPageSize === 'all' ? $n(effectiveTotal) : preferredPageSize,
898+ count: $n(effectiveTotal),
822899 },
823- visibleResults.total ,
900+ effectiveTotal ,
824901 )
825902 }}
826903 </p >
@@ -890,7 +967,7 @@ defineOgImageComponent('Default', {
890967 v-model:mode =" paginationMode"
891968 v-model:page-size =" preferredPageSize"
892969 v-model:current-page =" currentPage"
893- :total-items =" visibleResults?.total ?? displayResults.length "
970+ :total-items =" effectiveTotal "
894971 :view-mode =" viewMode"
895972 />
896973 </div >
0 commit comments