Skip to content

Commit f77c5ff

Browse files
committed
feat: allow filtering user/team packages
1 parent 68e11e2 commit f77c5ff

6 files changed

Lines changed: 409 additions & 101 deletions

File tree

app/components/PackageCard.vue

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,6 @@ defineProps<{
1010
showPublisher?: boolean
1111
prefetch?: boolean
1212
}>()
13-
14-
function formatDate(dateStr: string): string {
15-
return new Date(dateStr).toLocaleDateString('en-US', {
16-
year: 'numeric',
17-
month: 'short',
18-
day: 'numeric',
19-
})
20-
}
2113
</script>
2214

2315
<template>
@@ -68,7 +60,12 @@ function formatDate(dateStr: string): string {
6860
<div v-if="result.package.date" class="flex items-center gap-1.5">
6961
<dt class="sr-only">Updated</dt>
7062
<dd>
71-
<time :datetime="result.package.date">{{ formatDate(result.package.date) }}</time>
63+
<NuxtTime
64+
:datetime="result.package.date"
65+
year="numeric"
66+
month="short"
67+
day="numeric"
68+
/>
7269
</dd>
7370
</div>
7471
</dl>
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<script setup lang="ts">
2+
export type SortOption = 'downloads' | 'updated' | 'name-asc' | 'name-desc'
3+
4+
const props = defineProps<{
5+
/** Current search/filter text */
6+
filter: string
7+
/** Current sort option */
8+
sort: SortOption
9+
/** Placeholder text for the search input */
10+
placeholder?: string
11+
/** Total count of packages (before filtering) */
12+
totalCount?: number
13+
/** Filtered count of packages */
14+
filteredCount?: number
15+
}>()
16+
17+
const emit = defineEmits<{
18+
'update:filter': [value: string]
19+
'update:sort': [value: SortOption]
20+
}>()
21+
22+
const filterValue = computed({
23+
get: () => props.filter,
24+
set: value => emit('update:filter', value),
25+
})
26+
27+
const sortValue = computed({
28+
get: () => props.sort,
29+
set: value => emit('update:sort', value),
30+
})
31+
32+
const sortOptions = [
33+
{ value: 'downloads', label: 'Most downloaded' },
34+
{ value: 'updated', label: 'Recently updated' },
35+
{ value: 'name-asc', label: 'Name (A-Z)' },
36+
{ value: 'name-desc', label: 'Name (Z-A)' },
37+
] as const
38+
39+
// Show filter count when filtering is active
40+
const showFilteredCount = computed(() => {
41+
return (
42+
props.filter &&
43+
props.filteredCount !== undefined &&
44+
props.totalCount !== undefined &&
45+
props.filteredCount !== props.totalCount
46+
)
47+
})
48+
</script>
49+
50+
<template>
51+
<div class="flex flex-col sm:flex-row gap-3 mb-6">
52+
<!-- Filter input -->
53+
<div class="flex-1 relative">
54+
<label for="package-filter" class="sr-only">Filter packages</label>
55+
<span
56+
class="absolute left-3 top-1/2 -translate-y-1/2 text-fg-subtle pointer-events-none"
57+
aria-hidden="true"
58+
>
59+
<span class="i-carbon-search inline-block w-4 h-4" />
60+
</span>
61+
<input
62+
id="package-filter"
63+
v-model="filterValue"
64+
type="search"
65+
:placeholder="placeholder ?? 'Filter packages...'"
66+
autocomplete="off"
67+
class="w-full bg-bg-subtle border border-border rounded-lg pl-9 pr-4 py-2 font-mono text-sm text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:(border-border-hover outline-none)"
68+
/>
69+
</div>
70+
71+
<!-- Sort select -->
72+
<div class="relative shrink-0">
73+
<label for="package-sort" class="sr-only">Sort packages</label>
74+
<select
75+
id="package-sort"
76+
v-model="sortValue"
77+
class="appearance-none bg-bg-subtle border border-border rounded-lg pl-3 pr-8 py-2 font-mono text-sm text-fg cursor-pointer transition-colors duration-200 focus:(border-border-hover outline-none) hover:border-border-hover"
78+
>
79+
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
80+
{{ option.label }}
81+
</option>
82+
</select>
83+
<span
84+
class="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle pointer-events-none"
85+
aria-hidden="true"
86+
>
87+
<span class="i-carbon-chevron-down w-4 h-4" />
88+
</span>
89+
</div>
90+
</div>
91+
92+
<!-- Filtered count indicator -->
93+
<p v-if="showFilteredCount" class="text-fg-subtle text-xs font-mono mb-4">
94+
Showing {{ filteredCount }} of {{ totalCount }} packages
95+
</p>
96+
</template>

app/composables/useNpmRegistry.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import type {
33
PackumentVersion,
44
SlimPackument,
55
NpmSearchResponse,
6+
NpmSearchResult,
67
NpmDownloadCount,
8+
NpmPerson,
79
} from '#shared/types'
810

911
const NPM_REGISTRY = 'https://registry.npmjs.org'
@@ -216,3 +218,111 @@ export function useNpmSearch(
216218
{ default: () => lastSearch || emptySearchResponse },
217219
)
218220
}
221+
222+
/**
223+
* Fetch all package names in an npm organization
224+
* Uses the /-/org/{org}/package endpoint
225+
*/
226+
async function fetchOrgPackageNames(orgName: string): Promise<string[]> {
227+
const data = await $fetch<Record<string, string>>(
228+
`${NPM_REGISTRY}/-/org/${encodeURIComponent(orgName)}/package`,
229+
)
230+
return Object.keys(data)
231+
}
232+
233+
/**
234+
* Minimal packument data needed for package cards
235+
*/
236+
interface MinimalPackument {
237+
'name': string
238+
'description'?: string
239+
'dist-tags': Record<string, string>
240+
'time': Record<string, string>
241+
'maintainers'?: NpmPerson[]
242+
}
243+
244+
/**
245+
* Fetch minimal packument data for a single package
246+
*/
247+
async function fetchMinimalPackument(name: string): Promise<MinimalPackument | null> {
248+
try {
249+
const encoded = encodePackageName(name)
250+
return await $fetch<MinimalPackument>(`${NPM_REGISTRY}/${encoded}`, {
251+
// Only fetch the fields we need using Accept header
252+
// Note: npm registry doesn't support field filtering, so we get full packument
253+
// but we only use what we need
254+
})
255+
} catch {
256+
// Package might not exist or be private
257+
return null
258+
}
259+
}
260+
261+
/**
262+
* Convert packument to search result format for display
263+
*/
264+
function packumentToSearchResult(pkg: MinimalPackument): NpmSearchResult {
265+
const latestVersion = pkg['dist-tags'].latest || Object.values(pkg['dist-tags'])[0] || ''
266+
const modified = pkg.time.modified || pkg.time[latestVersion] || ''
267+
268+
return {
269+
package: {
270+
name: pkg.name,
271+
version: latestVersion,
272+
description: pkg.description,
273+
date: pkg.time[latestVersion] || modified,
274+
links: {
275+
npm: `https://www.npmjs.com/package/${pkg.name}`,
276+
},
277+
maintainers: pkg.maintainers,
278+
},
279+
score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } },
280+
searchScore: 0,
281+
updated: modified,
282+
}
283+
}
284+
285+
/**
286+
* Fetch all packages for an npm organization
287+
* Returns search-result-like objects for compatibility with PackageList
288+
*/
289+
export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
290+
return useLazyAsyncData(
291+
() => `org-packages:${toValue(orgName)}`,
292+
async () => {
293+
const org = toValue(orgName)
294+
if (!org) {
295+
return emptySearchResponse
296+
}
297+
298+
// Get all package names in the org
299+
const packageNames = await fetchOrgPackageNames(org)
300+
301+
if (packageNames.length === 0) {
302+
return emptySearchResponse
303+
}
304+
305+
// Fetch packuments in parallel (with concurrency limit)
306+
const concurrency = 10
307+
const results: NpmSearchResult[] = []
308+
309+
for (let i = 0; i < packageNames.length; i += concurrency) {
310+
const batch = packageNames.slice(i, i + concurrency)
311+
const packuments = await Promise.all(batch.map(name => fetchMinimalPackument(name)))
312+
313+
for (const pkg of packuments) {
314+
if (pkg) {
315+
results.push(packumentToSearchResult(pkg))
316+
}
317+
}
318+
}
319+
320+
return {
321+
objects: results,
322+
total: results.length,
323+
time: new Date().toISOString(),
324+
} satisfies NpmSearchResponse
325+
},
326+
{ default: () => emptySearchResponse },
327+
)
328+
}

0 commit comments

Comments
 (0)