[READ-ONLY] a fast, modern browser for the npm registry
at main 355 lines 13 kB view raw
1<script setup lang="ts"> 2import type { NpmSearchResult } from '#shared/types/npm-registry' 3import type { 4 ColumnConfig, 5 ColumnId, 6 SortKey, 7 SortOption, 8 StructuredFilters, 9} from '#shared/types/preferences' 10import { buildSortOption, parseSortOption, toggleDirection } from '#shared/types/preferences' 11 12const props = defineProps<{ 13 results: NpmSearchResult[] 14 columns: ColumnConfig[] 15 filters?: StructuredFilters 16 isLoading?: boolean 17}>() 18 19const { t } = useI18n() 20 21const sortOption = defineModel<SortOption>('sortOption') 22 23const emit = defineEmits<{ 24 clickKeyword: [keyword: string] 25}>() 26 27function isColumnVisible(id: string): boolean { 28 return props.columns.find(c => c.id === id)?.visible ?? false 29} 30 31function isSortable(id: string): boolean { 32 return props.columns.find(c => c.id === id)?.sortable ?? false 33} 34 35// Map column id to sort key 36const columnToSortKey: Record<string, SortKey> = { 37 name: 'name', 38 downloads: 'downloads-week', 39 updated: 'updated', 40 qualityScore: 'quality', 41 popularityScore: 'popularity', 42 maintenanceScore: 'maintenance', 43 combinedScore: 'score', 44} 45 46// Default direction for each column 47const columnDefaultDirection: Record<string, 'asc' | 'desc'> = { 48 name: 'asc', 49 downloads: 'desc', 50 updated: 'desc', 51 qualityScore: 'desc', 52 popularityScore: 'desc', 53 maintenanceScore: 'desc', 54 combinedScore: 'desc', 55} 56 57function isColumnSorted(id: string): boolean { 58 const option = sortOption.value 59 if (!option) return false 60 const { key } = parseSortOption(option) 61 return key === columnToSortKey[id] 62} 63 64function getSortDirection(id: string): 'asc' | 'desc' | null { 65 const option = sortOption.value 66 if (!option) return null 67 if (!isColumnSorted(id)) return null 68 const { direction } = parseSortOption(option) 69 return direction 70} 71 72function toggleSort(id: string) { 73 if (!isSortable(id)) return 74 75 const sortKey = columnToSortKey[id] 76 if (!sortKey) return 77 78 const isSorted = isColumnSorted(id) 79 80 if (!isSorted) { 81 // First click - use default direction 82 const defaultDir = columnDefaultDirection[id] ?? 'desc' 83 sortOption.value = buildSortOption(sortKey, defaultDir) 84 } else { 85 // Toggle direction 86 const currentDir = getSortDirection(id) ?? 'desc' 87 sortOption.value = buildSortOption(sortKey, toggleDirection(currentDir)) 88 } 89} 90 91// Map column IDs to i18n keys 92const columnLabels = computed(() => ({ 93 name: t('filters.columns.name'), 94 version: t('filters.columns.version'), 95 description: t('filters.columns.description'), 96 downloads: t('filters.columns.downloads'), 97 updated: t('filters.columns.published'), 98 maintainers: t('filters.columns.maintainers'), 99 keywords: t('filters.columns.keywords'), 100 qualityScore: t('filters.columns.quality_score'), 101 popularityScore: t('filters.columns.popularity_score'), 102 maintenanceScore: t('filters.columns.maintenance_score'), 103 combinedScore: t('filters.columns.combined_score'), 104 security: t('filters.columns.security'), 105})) 106 107function getColumnLabel(id: ColumnId): string { 108 return columnLabels.value[id] 109} 110</script> 111 112<template> 113 <div class="overflow-x-auto"> 114 <table class="w-full text-start"> 115 <thead class="border-b border-border"> 116 <tr> 117 <!-- Name (always visible) --> 118 <th 119 scope="col" 120 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-inset focus-visible:outline-none" 121 :class="{ 122 'hover:text-fg transition-colors duration-200': isSortable('name'), 123 }" 124 :aria-sort=" 125 isColumnSorted('name') 126 ? getSortDirection('name') === 'asc' 127 ? 'ascending' 128 : 'descending' 129 : undefined 130 " 131 :tabindex="isSortable('name') ? 0 : undefined" 132 role="columnheader" 133 @click="toggleSort('name')" 134 @keydown.enter="toggleSort('name')" 135 @keydown.space.prevent="toggleSort('name')" 136 > 137 <span class="inline-flex items-center gap-1"> 138 {{ getColumnLabel('name') }} 139 <template v-if="isSortable('name')"> 140 <span 141 v-if="isColumnSorted('name')" 142 class="i-lucide:chevron-down w-3 h-3" 143 :class="getSortDirection('name') === 'asc' ? 'rotate-180' : ''" 144 aria-hidden="true" 145 /> 146 <span 147 v-else 148 class="i-lucide:chevrons-up-down w-3 h-3 opacity-30" 149 aria-hidden="true" 150 /> 151 </template> 152 </span> 153 </th> 154 155 <th 156 v-if="isColumnVisible('version')" 157 scope="col" 158 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none" 159 > 160 {{ getColumnLabel('version') }} 161 </th> 162 163 <th 164 v-if="isColumnVisible('description')" 165 scope="col" 166 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none" 167 > 168 {{ getColumnLabel('description') }} 169 </th> 170 171 <th 172 v-if="isColumnVisible('downloads')" 173 scope="col" 174 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-inset focus-visible:outline-none" 175 :class="{ 176 'hover:text-fg transition-colors duration-200': isSortable('downloads'), 177 }" 178 :aria-sort=" 179 isColumnSorted('downloads') 180 ? getSortDirection('downloads') === 'asc' 181 ? 'ascending' 182 : 'descending' 183 : undefined 184 " 185 :tabindex="isSortable('downloads') ? 0 : undefined" 186 role="columnheader" 187 @click="toggleSort('downloads')" 188 @keydown.enter="toggleSort('downloads')" 189 @keydown.space.prevent="toggleSort('downloads')" 190 > 191 <span class="inline-flex items-center gap-1 justify-end"> 192 {{ getColumnLabel('downloads') }} 193 <template v-if="isSortable('downloads')"> 194 <span 195 v-if="isColumnSorted('downloads')" 196 class="i-lucide:caret-down w-3 h-3" 197 :class="getSortDirection('downloads') === 'asc' ? 'rotate-180' : ''" 198 aria-hidden="true" 199 /> 200 <span 201 v-else 202 class="i-lucide:chevrons-up-down w-3 h-3 opacity-30" 203 aria-hidden="true" 204 /> 205 </template> 206 </span> 207 </th> 208 209 <th 210 v-if="isColumnVisible('updated')" 211 scope="col" 212 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-inset focus-visible:outline-none" 213 :class="{ 214 'hover:text-fg transition-colors duration-200': isSortable('updated'), 215 }" 216 :aria-sort=" 217 isColumnSorted('updated') 218 ? getSortDirection('updated') === 'asc' 219 ? 'ascending' 220 : 'descending' 221 : undefined 222 " 223 :tabindex="isSortable('updated') ? 0 : undefined" 224 role="columnheader" 225 @click="toggleSort('updated')" 226 @keydown.enter="toggleSort('updated')" 227 @keydown.space.prevent="toggleSort('updated')" 228 > 229 <span class="inline-flex items-center gap-1"> 230 {{ getColumnLabel('updated') }} 231 <template v-if="isSortable('updated')"> 232 <span 233 v-if="isColumnSorted('updated')" 234 class="i-lucide:caret-down w-3 h-3" 235 :class="getSortDirection('updated') === 'asc' ? 'rotate-180' : ''" 236 aria-hidden="true" 237 /> 238 <span 239 v-else 240 class="i-lucide:chevrons-up-down w-3 h-3 opacity-30" 241 aria-hidden="true" 242 /> 243 </template> 244 </span> 245 </th> 246 247 <th 248 v-if="isColumnVisible('maintainers')" 249 scope="col" 250 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end" 251 > 252 {{ getColumnLabel('maintainers') }} 253 </th> 254 255 <th 256 v-if="isColumnVisible('keywords')" 257 scope="col" 258 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end" 259 > 260 {{ getColumnLabel('keywords') }} 261 </th> 262 263 <th 264 v-if="isColumnVisible('qualityScore')" 265 scope="col" 266 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end" 267 > 268 {{ getColumnLabel('qualityScore') }} 269 </th> 270 271 <th 272 v-if="isColumnVisible('popularityScore')" 273 scope="col" 274 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end" 275 > 276 {{ getColumnLabel('popularityScore') }} 277 </th> 278 279 <th 280 v-if="isColumnVisible('maintenanceScore')" 281 scope="col" 282 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end" 283 > 284 {{ getColumnLabel('maintenanceScore') }} 285 </th> 286 287 <th 288 v-if="isColumnVisible('combinedScore')" 289 scope="col" 290 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end" 291 > 292 {{ getColumnLabel('combinedScore') }} 293 </th> 294 295 <th 296 v-if="isColumnVisible('security')" 297 scope="col" 298 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end" 299 > 300 {{ getColumnLabel('security') }} 301 </th> 302 </tr> 303 </thead> 304 <tbody> 305 <!-- Loading skeleton rows --> 306 <template v-if="isLoading && results.length === 0"> 307 <tr v-for="i in 5" :key="`skeleton-${i}`" class="border-b border-border"> 308 <td class="py-3 px-3"> 309 <div class="h-4 w-32 bg-bg-muted rounded animate-pulse" /> 310 </td> 311 <td v-if="isColumnVisible('version')" class="py-3 px-3"> 312 <div class="h-4 w-12 bg-bg-muted rounded animate-pulse" /> 313 </td> 314 <td v-if="isColumnVisible('description')" class="py-3 px-3"> 315 <div class="h-4 w-48 bg-bg-muted rounded animate-pulse" /> 316 </td> 317 <td v-if="isColumnVisible('downloads')" class="py-3 px-3"> 318 <div class="h-4 w-16 bg-bg-muted rounded animate-pulse ms-auto" /> 319 </td> 320 <td v-if="isColumnVisible('updated')" class="py-3 px-3"> 321 <div class="h-4 w-20 bg-bg-muted rounded animate-pulse ms-auto" /> 322 </td> 323 <td v-if="isColumnVisible('maintainers')" class="py-3 px-3"> 324 <div class="h-4 w-24 bg-bg-muted rounded animate-pulse ms-auto" /> 325 </td> 326 <td v-if="isColumnVisible('keywords')" class="py-3 px-3"> 327 <div class="h-4 w-32 bg-bg-muted rounded animate-pulse ms-auto" /> 328 </td> 329 </tr> 330 </template> 331 332 <!-- Actual data rows --> 333 <template v-else> 334 <PackageTableRow 335 v-for="(result, index) in results" 336 :key="result.package.name" 337 :result="result" 338 :columns="columns" 339 :index="index" 340 :filters="filters" 341 @click-keyword="emit('clickKeyword', $event)" 342 /> 343 </template> 344 </tbody> 345 </table> 346 347 <!-- Empty state (only when not loading) --> 348 <div 349 v-if="results.length === 0 && !isLoading" 350 class="py-12 text-center text-fg-subtle font-mono text-sm" 351 > 352 {{ $t('filters.table.no_packages') }} 353 </div> 354 </div> 355</template>