[READ-ONLY] a fast, modern browser for the npm registry
at main 216 lines 6.7 kB view raw
1<script setup lang="ts"> 2import type { NpmSearchResult } from '#shared/types/npm-registry' 3import type { ColumnConfig, StructuredFilters } from '#shared/types/preferences' 4 5const props = defineProps<{ 6 result: NpmSearchResult 7 columns: ColumnConfig[] 8 index?: number 9 filters?: StructuredFilters 10}>() 11 12const emit = defineEmits<{ 13 clickKeyword: [keyword: string] 14}>() 15 16const pkg = computed(() => props.result.package) 17const score = computed(() => props.result.score) 18 19const updatedDate = computed(() => props.result.package.date) 20 21function formatDownloads(count?: number): string { 22 if (count === undefined) return '-' 23 if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M` 24 if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K` 25 return count.toString() 26} 27 28function formatScore(value?: number): string { 29 if (value === undefined || value === 0) return '-' 30 return Math.round(value * 100).toString() 31} 32 33function isColumnVisible(id: string): boolean { 34 return props.columns.find(c => c.id === id)?.visible ?? false 35} 36 37const packageUrl = computed(() => packageRoute(pkg.value.name)) 38 39const allMaintainersText = computed(() => { 40 if (!pkg.value.maintainers?.length) return '' 41 return pkg.value.maintainers.map(m => m.name || m.email).join(', ') 42}) 43</script> 44 45<template> 46 <tr 47 class="group relative border-b border-border hover:bg-bg-muted transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-inset focus-visible:outline-none focus:bg-bg-muted" 48 tabindex="0" 49 :data-result-index="index" 50 > 51 <!-- Name (always visible) --> 52 <td class="py-2 px-3"> 53 <NuxtLink 54 :to="packageUrl" 55 class="row-link font-mono text-sm text-fg hover:text-accent-fallback transition-colors duration-200" 56 dir="ltr" 57 > 58 {{ pkg.name }} 59 </NuxtLink> 60 </td> 61 62 <!-- Version --> 63 <td v-if="isColumnVisible('version')" class="py-2 px-3 font-mono text-xs text-fg-subtle"> 64 <span dir="ltr">{{ pkg.version }}</span> 65 </td> 66 67 <!-- Description --> 68 <td 69 v-if="isColumnVisible('description')" 70 class="py-2 px-3 text-sm text-fg-muted max-w-xs truncate" 71 > 72 {{ pkg.description || '-' }} 73 </td> 74 75 <!-- Downloads --> 76 <td 77 v-if="isColumnVisible('downloads')" 78 class="py-2 px-3 font-mono text-xs text-fg-muted text-end tabular-nums" 79 > 80 {{ formatDownloads(result.downloads?.weekly) }} 81 </td> 82 83 <!-- Updated --> 84 <td 85 v-if="isColumnVisible('updated')" 86 class="py-2 px-3 font-mono text-end text-xs text-fg-muted" 87 > 88 <DateTime 89 v-if="updatedDate" 90 :datetime="updatedDate" 91 year="numeric" 92 month="short" 93 day="numeric" 94 /> 95 <span v-else>-</span> 96 </td> 97 98 <!-- Maintainers --> 99 <td v-if="isColumnVisible('maintainers')" class="py-2 px-3 text-sm text-fg-muted text-end"> 100 <span 101 v-if="pkg.maintainers?.length" 102 :title="pkg.maintainers.length > 3 ? allMaintainersText : undefined" 103 :class="{ 'cursor-help': pkg.maintainers.length > 3 }" 104 > 105 <template 106 v-for="(maintainer, idx) in pkg.maintainers.slice(0, 3)" 107 :key="maintainer.username || maintainer.email" 108 > 109 <NuxtLink 110 :to="{ 111 name: '~username', 112 params: { username: maintainer.username || maintainer.name || '' }, 113 }" 114 class="relative z-10 hover:text-accent-fallback transition-colors duration-200" 115 @click.stop 116 >{{ maintainer.username || maintainer.name || maintainer.email }}</NuxtLink 117 ><span v-if="idx < Math.min(pkg.maintainers.length, 3) - 1">, </span> 118 </template> 119 <span v-if="pkg.maintainers.length > 3" class="text-fg-subtle"> 120 +{{ pkg.maintainers.length - 3 }} 121 </span> 122 </span> 123 <span v-else class="text-fg-subtle">-</span> 124 </td> 125 126 <!-- Keywords --> 127 <td v-if="isColumnVisible('keywords')" class="py-2 px-3 text-end"> 128 <div 129 v-if="pkg.keywords?.length" 130 class="relative z-10 flex flex-wrap gap-1 justify-end" 131 :aria-label="$t('package.card.keywords')" 132 > 133 <ButtonBase 134 v-for="keyword in pkg.keywords.slice(0, 3)" 135 :key="keyword" 136 size="small" 137 :aria-pressed="props.filters?.keywords.includes(keyword)" 138 :title="`Filter by ${keyword}`" 139 @click.stop="emit('clickKeyword', keyword)" 140 :class="{ 'group-hover:bg-bg-elevated': !props.filters?.keywords.includes(keyword) }" 141 > 142 {{ keyword }} 143 </ButtonBase> 144 <span 145 v-if="pkg.keywords.length > 3" 146 class="text-fg-subtle text-xs" 147 :title="pkg.keywords.slice(3).join(', ')" 148 > 149 +{{ pkg.keywords.length - 3 }} 150 </span> 151 </div> 152 <span v-else class="text-fg-subtle">-</span> 153 </td> 154 155 <!-- Quality Score --> 156 <td 157 v-if="isColumnVisible('qualityScore')" 158 class="py-2 px-3 font-mono text-xs text-fg-muted text-end tabular-nums" 159 > 160 {{ formatScore(score?.detail?.quality) }} 161 </td> 162 163 <!-- Popularity Score --> 164 <td 165 v-if="isColumnVisible('popularityScore')" 166 class="py-2 px-3 font-mono text-xs text-fg-muted text-end tabular-nums" 167 > 168 {{ formatScore(score?.detail?.popularity) }} 169 </td> 170 171 <!-- Maintenance Score --> 172 <td 173 v-if="isColumnVisible('maintenanceScore')" 174 class="py-2 px-3 font-mono text-xs text-fg-muted text-end tabular-nums" 175 > 176 {{ formatScore(score?.detail?.maintenance) }} 177 </td> 178 179 <!-- Combined Score --> 180 <td 181 v-if="isColumnVisible('combinedScore')" 182 class="py-2 px-3 font-mono text-xs text-fg-muted text-end tabular-nums" 183 > 184 {{ formatScore(score?.final) }} 185 </td> 186 187 <!-- Security --> 188 <td v-if="isColumnVisible('security')" class="py-2 px-3"> 189 <span v-if="result.flags?.insecure" class="text-syntax-kw"> 190 <span class="i-lucide:circle-alert w-4 h-4" aria-hidden="true" /> 191 <span class="sr-only">{{ $t('filters.table.security_warning') }}</span> 192 </span> 193 <span v-else-if="result.flags !== undefined" class="text-provider-nuxt"> 194 <span class="i-lucide:check w-4 h-4" aria-hidden="true" /> 195 <span class="sr-only">{{ $t('filters.table.secure') }}</span> 196 </span> 197 <span v-else class="text-fg-subtle"> - </span> 198 </td> 199 </tr> 200</template> 201 202<style scoped> 203.row-link { 204 &::after { 205 content: ''; 206 position: absolute; 207 inset: 0; 208 cursor: pointer; 209 } 210 211 &:focus-visible::after { 212 outline: 2px solid var(--color-fg); 213 outline-offset: -2px; 214 } 215} 216</style>