[READ-ONLY] a fast, modern browser for the npm registry
at main 290 lines 9.8 kB view raw
1<script setup lang="ts"> 2import type { NpmSearchResult } from '#shared/types' 3import type { WindowVirtualizerHandle } from '~/composables/useVirtualInfiniteScroll' 4import type { 5 ColumnConfig, 6 PageSize, 7 PaginationMode, 8 SortOption, 9 ViewMode, 10} from '#shared/types/preferences' 11import { DEFAULT_COLUMNS } from '#shared/types/preferences' 12import { WindowVirtualizer } from 'virtua/vue' 13 14/** Number of items to render statically during SSR */ 15const SSR_COUNT = 20 16 17const props = defineProps<{ 18 /** List of search results to display */ 19 results: NpmSearchResult[] 20 /** Filters to apply to the results */ 21 filters?: StructuredFilters 22 /** Heading level for package names */ 23 headingLevel?: 'h2' | 'h3' 24 /** Whether to show publisher username on cards */ 25 showPublisher?: boolean 26 /** Whether there are more items to load */ 27 hasMore?: boolean 28 /** Whether currently loading more items */ 29 isLoading?: boolean 30 /** Page size for tracking current page */ 31 pageSize?: PageSize 32 /** Initial page to scroll to (1-indexed) */ 33 initialPage?: number 34 /** Search query for highlighting exact matches */ 35 searchQuery?: string 36 /** View mode: cards or table */ 37 viewMode?: ViewMode 38 /** Column configuration for table view */ 39 columns?: ColumnConfig[] 40 /** Pagination mode: infinite or paginated */ 41 paginationMode?: PaginationMode 42 /** Current page (1-indexed) for paginated mode */ 43 currentPage?: number 44 /** When true, shows search-specific UI (relevance sort, no filters) */ 45 searchContext?: boolean 46}>() 47 48const emit = defineEmits<{ 49 /** Emitted when scrolled near the bottom and more items should be loaded */ 50 'loadMore': [] 51 /** Emitted when the visible page changes */ 52 'pageChange': [page: number] 53 /** Emitted when sort option changes (table view) */ 54 'update:sortOption': [option: SortOption] 55 /** Emitted when a keyword is clicked */ 56 'clickKeyword': [keyword: string] 57}>() 58 59// Reference to WindowVirtualizer for infinite scroll detection 60const listRef = useTemplateRef<WindowVirtualizerHandle>('listRef') 61 62/** Sort option for table header sorting */ 63const sortOption = defineModel<SortOption>('sortOption') 64 65// View mode and columns 66const viewMode = computed(() => props.viewMode ?? 'cards') 67const columns = computed(() => { 68 const targetColumns = props.columns ?? DEFAULT_COLUMNS 69 if (props.searchContext) return targetColumns.map(column => ({ ...column, sortable: false })) 70 return targetColumns 71}) 72// Table view forces pagination mode (no virtualization for tables) 73const paginationMode = computed(() => 74 viewMode.value === 'table' ? 'paginated' : (props.paginationMode ?? 'infinite'), 75) 76const currentPage = computed(() => props.currentPage ?? 1) 77const pageSize = computed(() => props.pageSize ?? 25) 78// Numeric page size for virtual scroll and arithmetic (when 'all' is selected, use 25 as default) 79const numericPageSize = computed(() => (pageSize.value === 'all' ? 25 : pageSize.value)) 80 81// Compute paginated results for paginated mode 82const displayedResults = computed(() => { 83 if (paginationMode.value === 'infinite') { 84 return props.results 85 } 86 // 'all' page size means show everything (YOLO) 87 if (pageSize.value === 'all') { 88 return props.results 89 } 90 const start = (currentPage.value - 1) * numericPageSize.value 91 const end = start + numericPageSize.value 92 return props.results.slice(start, end) 93}) 94 95// Set up infinite scroll if hasMore is provided 96const hasMore = computed(() => props.hasMore ?? false) 97const isLoading = computed(() => props.isLoading ?? false) 98const itemCount = computed(() => props.results.length) 99 100const { handleScroll, scrollToPage } = useVirtualInfiniteScroll({ 101 listRef, 102 itemCount, 103 hasMore, 104 isLoading, 105 pageSize: numericPageSize, 106 threshold: 5, 107 onLoadMore: () => emit('loadMore'), 108 onPageChange: page => emit('pageChange', page), 109}) 110 111// Scroll to initial page once list is ready and has items 112const hasScrolledToInitial = shallowRef(false) 113 114watch( 115 [() => props.results.length, () => props.initialPage, listRef], 116 ([length, initialPage, list]) => { 117 if (!hasScrolledToInitial.value && list && length > 0 && initialPage && initialPage > 1) { 118 // Wait for next tick to ensure list is rendered 119 nextTick(() => { 120 scrollToPage(initialPage) 121 hasScrolledToInitial.value = true 122 }) 123 } 124 }, 125 { immediate: true }, 126) 127 128// Reset scroll state when results change significantly (new search) 129watch( 130 () => props.results, 131 (newResults, oldResults) => { 132 // If this looks like a new search (different first item or much shorter), reset 133 if ( 134 !oldResults || 135 newResults.length === 0 || 136 (oldResults.length > 0 && newResults[0]?.package.name !== oldResults[0]?.package.name) 137 ) { 138 hasScrolledToInitial.value = false 139 } 140 }, 141) 142 143function scrollToIndex(index: number, smooth = true) { 144 listRef.value?.scrollToIndex(index, { align: 'center', smooth }) 145} 146 147defineExpose({ 148 scrollToIndex, 149}) 150</script> 151 152<template> 153 <div> 154 <!-- Table View --> 155 <template v-if="viewMode === 'table'"> 156 <PackageTable 157 :results="displayedResults" 158 :filters="filters" 159 :columns="columns" 160 v-model:sort-option="sortOption" 161 :is-loading="isLoading" 162 @click-keyword="emit('clickKeyword', $event)" 163 /> 164 </template> 165 166 <!-- Card View with Infinite Scroll --> 167 <template v-else-if="paginationMode === 'infinite'"> 168 <!-- SSR: Render static list for first page, replaced by virtual list on client --> 169 <ClientOnly> 170 <WindowVirtualizer 171 ref="listRef" 172 :data="results" 173 :item-size="140" 174 as="ol" 175 item="li" 176 class="list-none m-0 p-0" 177 @scroll="handleScroll" 178 > 179 <template #default="{ item, index }"> 180 <div class="pb-4"> 181 <PackageCard 182 :result="item as NpmSearchResult" 183 :heading-level="headingLevel" 184 :show-publisher="showPublisher" 185 :index="index" 186 :search-query="searchQuery" 187 class="motion-safe:animate-fade-in motion-safe:animate-fill-both" 188 :filters="filters" 189 :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" 190 @click-keyword="emit('clickKeyword', $event)" 191 /> 192 </div> 193 </template> 194 </WindowVirtualizer> 195 196 <!-- SSR fallback: static list of first page results --> 197 <template #fallback> 198 <ol class="list-none m-0 p-0"> 199 <li v-for="(item, index) in results.slice(0, SSR_COUNT)" :key="item.package.name"> 200 <div class="pb-4"> 201 <PackageCard 202 :result="item" 203 :heading-level="headingLevel" 204 :show-publisher="showPublisher" 205 :index="index" 206 :search-query="searchQuery" 207 :filters="filters" 208 @click-keyword="emit('clickKeyword', $event)" 209 /> 210 </div> 211 </li> 212 </ol> 213 </template> 214 </ClientOnly> 215 </template> 216 217 <!-- Card View with Pagination --> 218 <template v-else> 219 <!-- Loading state when fetching page data --> 220 <div 221 v-if="isLoading && displayedResults.length === 0" 222 class="py-12 flex items-center justify-center" 223 > 224 <div class="flex items-center gap-3 text-fg-muted font-mono text-sm"> 225 <span 226 class="w-5 h-5 border-2 border-fg-subtle border-t-fg rounded-full motion-safe:animate-spin" 227 /> 228 {{ $t('common.loading') }} 229 </div> 230 </div> 231 <ol v-else class="list-none m-0 p-0"> 232 <li v-for="(item, index) in displayedResults" :key="item.package.name" class="pb-4"> 233 <PackageCard 234 :result="item" 235 :heading-level="headingLevel" 236 :show-publisher="showPublisher" 237 :index="index" 238 :search-query="searchQuery" 239 class="motion-safe:animate-fade-in motion-safe:animate-fill-both" 240 :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" 241 :filters="filters" 242 @click-keyword="emit('clickKeyword', $event)" 243 /> 244 </li> 245 </ol> 246 </template> 247 248 <!-- Initial loading state (card view only - table has its own skeleton) --> 249 <div 250 v-if="isLoading && results.length === 0 && viewMode !== 'table'" 251 class="py-12 flex items-center justify-center" 252 > 253 <div class="flex items-center gap-3 text-fg-muted font-mono text-sm"> 254 <span 255 class="w-5 h-5 border-2 border-fg-subtle border-t-fg rounded-full motion-safe:animate-spin" 256 /> 257 {{ $t('common.loading') }} 258 </div> 259 </div> 260 261 <!-- Loading more indicator (infinite scroll mode only) --> 262 <div 263 v-else-if="isLoading && paginationMode === 'infinite'" 264 class="py-4 flex items-center justify-center" 265 > 266 <div class="flex items-center gap-3 text-fg-muted font-mono text-sm"> 267 <span 268 class="w-4 h-4 border-2 border-fg-subtle border-t-fg rounded-full motion-safe:animate-spin" 269 /> 270 {{ $t('common.loading_more') }} 271 </div> 272 </div> 273 274 <!-- End of results (infinite scroll mode only) --> 275 <p 276 v-else-if="!hasMore && results.length > 0 && paginationMode === 'infinite'" 277 class="py-4 text-center text-fg-subtle font-mono text-sm" 278 > 279 {{ $t('common.end_of_results') }} 280 </p> 281 282 <!-- Empty state (card view only - table has its own) --> 283 <p 284 v-if="results.length === 0 && !isLoading && viewMode !== 'table'" 285 class="py-12 text-center text-fg-subtle font-mono text-sm" 286 > 287 {{ $t('filters.table.no_packages') }} 288 </p> 289 </div> 290</template>