forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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>