Skip to content

Commit 1a4e7f2

Browse files
committed
feat: mvp
1 parent e39e56c commit 1a4e7f2

27 files changed

Lines changed: 2573 additions & 94 deletions

app/app.vue

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,44 @@
11
<script setup lang="ts">
2-
defineOgImage()
3-
4-
useSeoMeta({
5-
title: 'Nuxt Starter',
2+
useHead({
3+
titleTemplate: (titleChunk) => {
4+
return titleChunk ? titleChunk : 'npmx - Better npm Package Browser'
5+
},
66
})
77
</script>
88

99
<template>
1010
<div>
11-
<NuxtPage />
11+
<a
12+
href="#main-content"
13+
class="skip-link"
14+
>Skip to main content</a>
15+
<header>
16+
<nav aria-label="Main navigation">
17+
<NuxtLink
18+
to="/"
19+
aria-label="npmx home"
20+
>
21+
npmx
22+
</NuxtLink>
23+
<ul>
24+
<li>
25+
<NuxtLink to="/search">
26+
Search
27+
</NuxtLink>
28+
</li>
29+
</ul>
30+
</nav>
31+
</header>
32+
<div id="main-content">
33+
<NuxtPage />
34+
</div>
35+
<footer>
36+
<p>
37+
<a
38+
href="https://github.com/danielroe/npmx.dev"
39+
rel="noopener noreferrer"
40+
>Source on GitHub</a>
41+
</p>
42+
</footer>
1243
</div>
1344
</template>

app/components/PackageSkeleton.vue

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<template>
2+
<article
3+
aria-busy="true"
4+
aria-label="Loading package details"
5+
>
6+
<header>
7+
<h1><span data-skeleton>&nbsp;</span></h1>
8+
<p><span data-skeleton>&nbsp;</span></p>
9+
10+
<dl>
11+
<div>
12+
<dt>Version</dt>
13+
<dd><span data-skeleton>&nbsp;</span></dd>
14+
</div>
15+
16+
<div>
17+
<dt>License</dt>
18+
<dd><span data-skeleton>&nbsp;</span></dd>
19+
</div>
20+
21+
<div>
22+
<dt>Weekly Downloads</dt>
23+
<dd><span data-skeleton>&nbsp;</span></dd>
24+
</div>
25+
26+
<div>
27+
<dt>Unpacked Size</dt>
28+
<dd><span data-skeleton>&nbsp;</span></dd>
29+
</div>
30+
31+
<div>
32+
<dt>Dependencies</dt>
33+
<dd><span data-skeleton>&nbsp;</span></dd>
34+
</div>
35+
36+
<div>
37+
<dt>Last Published</dt>
38+
<dd><span data-skeleton>&nbsp;</span></dd>
39+
</div>
40+
</dl>
41+
42+
<nav aria-label="Package links">
43+
<ul>
44+
<li><span data-skeleton>&nbsp;</span></li>
45+
<li><span data-skeleton>&nbsp;</span></li>
46+
<li><span data-skeleton>&nbsp;</span></li>
47+
</ul>
48+
</nav>
49+
</header>
50+
51+
<section aria-labelledby="install-heading-skeleton">
52+
<h2 id="install-heading-skeleton">
53+
Install
54+
</h2>
55+
<pre><code><span data-skeleton>&nbsp;</span></code></pre>
56+
</section>
57+
58+
<section aria-labelledby="maintainers-heading-skeleton">
59+
<h2 id="maintainers-heading-skeleton">
60+
Maintainers
61+
</h2>
62+
<ul>
63+
<li><span data-skeleton>&nbsp;</span></li>
64+
<li><span data-skeleton>&nbsp;</span></li>
65+
</ul>
66+
</section>
67+
68+
<section aria-labelledby="keywords-heading-skeleton">
69+
<h2 id="keywords-heading-skeleton">
70+
Keywords
71+
</h2>
72+
<ul>
73+
<li><span data-skeleton>&nbsp;</span></li>
74+
<li><span data-skeleton>&nbsp;</span></li>
75+
<li><span data-skeleton>&nbsp;</span></li>
76+
</ul>
77+
</section>
78+
79+
<section aria-labelledby="versions-heading-skeleton">
80+
<h2 id="versions-heading-skeleton">
81+
Versions
82+
</h2>
83+
<table>
84+
<thead>
85+
<tr>
86+
<th scope="col">
87+
Version
88+
</th>
89+
<th scope="col">
90+
Published
91+
</th>
92+
</tr>
93+
</thead>
94+
<tbody>
95+
<tr>
96+
<td><span data-skeleton>&nbsp;</span></td>
97+
<td><span data-skeleton>&nbsp;</span></td>
98+
</tr>
99+
<tr>
100+
<td><span data-skeleton>&nbsp;</span></td>
101+
<td><span data-skeleton>&nbsp;</span></td>
102+
</tr>
103+
<tr>
104+
<td><span data-skeleton>&nbsp;</span></td>
105+
<td><span data-skeleton>&nbsp;</span></td>
106+
</tr>
107+
<tr>
108+
<td><span data-skeleton>&nbsp;</span></td>
109+
<td><span data-skeleton>&nbsp;</span></td>
110+
</tr>
111+
<tr>
112+
<td><span data-skeleton>&nbsp;</span></td>
113+
<td><span data-skeleton>&nbsp;</span></td>
114+
</tr>
115+
</tbody>
116+
</table>
117+
</section>
118+
119+
<section aria-labelledby="dependencies-heading-skeleton">
120+
<h2 id="dependencies-heading-skeleton">
121+
Dependencies
122+
</h2>
123+
<ul>
124+
<li><span data-skeleton>&nbsp;</span></li>
125+
<li><span data-skeleton>&nbsp;</span></li>
126+
<li><span data-skeleton>&nbsp;</span></li>
127+
</ul>
128+
</section>
129+
130+
<section aria-labelledby="readme-heading-skeleton">
131+
<h2 id="readme-heading-skeleton">
132+
Readme
133+
</h2>
134+
<div>
135+
<p><span data-skeleton>&nbsp;</span></p>
136+
<p><span data-skeleton>&nbsp;</span></p>
137+
<p><span data-skeleton>&nbsp;</span></p>
138+
<p><span data-skeleton>&nbsp;</span></p>
139+
</div>
140+
</section>
141+
</article>
142+
</template>

app/composables/useNpmRegistry.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type {
2+
Packument,
3+
NpmSearchResponse,
4+
NpmDownloadCount,
5+
NpmDownloadRange,
6+
} from '#shared/types'
7+
8+
const NPM_REGISTRY = 'https://registry.npmjs.org'
9+
const NPM_API = 'https://api.npmjs.org'
10+
11+
export function useNpmRegistry() {
12+
async function fetchPackage(name: string): Promise<Packument> {
13+
const encodedName = encodePackageName(name)
14+
return await $fetch<Packument>(`${NPM_REGISTRY}/${encodedName}`)
15+
}
16+
17+
async function searchPackages(
18+
query: string,
19+
options: {
20+
size?: number
21+
from?: number
22+
quality?: number
23+
popularity?: number
24+
maintenance?: number
25+
} = {},
26+
): Promise<NpmSearchResponse> {
27+
const params = new URLSearchParams()
28+
params.set('text', query)
29+
if (options.size) params.set('size', String(options.size))
30+
if (options.from) params.set('from', String(options.from))
31+
if (options.quality !== undefined) params.set('quality', String(options.quality))
32+
if (options.popularity !== undefined) params.set('popularity', String(options.popularity))
33+
if (options.maintenance !== undefined) params.set('maintenance', String(options.maintenance))
34+
35+
return await $fetch<NpmSearchResponse>(`${NPM_REGISTRY}/-/v1/search?${params.toString()}`)
36+
}
37+
38+
async function fetchDownloads(
39+
packageName: string,
40+
period: 'last-day' | 'last-week' | 'last-month' | 'last-year' = 'last-week',
41+
): Promise<NpmDownloadCount> {
42+
const encodedName = encodePackageName(packageName)
43+
return await $fetch<NpmDownloadCount>(`${NPM_API}/downloads/point/${period}/${encodedName}`)
44+
}
45+
46+
async function fetchDownloadRange(
47+
packageName: string,
48+
period: 'last-week' | 'last-month' | 'last-year' = 'last-month',
49+
): Promise<NpmDownloadRange> {
50+
const encodedName = encodePackageName(packageName)
51+
return await $fetch<NpmDownloadRange>(`${NPM_API}/downloads/range/${period}/${encodedName}`)
52+
}
53+
54+
return {
55+
fetchPackage,
56+
searchPackages,
57+
fetchDownloads,
58+
fetchDownloadRange,
59+
}
60+
}
61+
62+
function encodePackageName(name: string): string {
63+
if (name.startsWith('@')) {
64+
return `@${encodeURIComponent(name.slice(1))}`
65+
}
66+
return encodeURIComponent(name)
67+
}
68+
69+
export function usePackage(name: MaybeRefOrGetter<string>) {
70+
const registry = useNpmRegistry()
71+
72+
return useAsyncData(
73+
`package:${toValue(name)}`,
74+
() => registry.fetchPackage(toValue(name)),
75+
{ watch: [() => toValue(name)] },
76+
)
77+
}
78+
79+
export function usePackageDownloads(
80+
name: MaybeRefOrGetter<string>,
81+
period: MaybeRefOrGetter<'last-day' | 'last-week' | 'last-month' | 'last-year'> = 'last-week',
82+
) {
83+
const registry = useNpmRegistry()
84+
85+
return useAsyncData(
86+
`downloads:${toValue(name)}:${toValue(period)}`,
87+
() => registry.fetchDownloads(toValue(name), toValue(period)),
88+
{ watch: [() => toValue(name), () => toValue(period)] },
89+
)
90+
}
91+
92+
export function useNpmSearch(
93+
query: MaybeRefOrGetter<string>,
94+
options: MaybeRefOrGetter<{
95+
size?: number
96+
from?: number
97+
}> = {},
98+
) {
99+
const registry = useNpmRegistry()
100+
101+
return useAsyncData(
102+
`search:${toValue(query)}:${JSON.stringify(toValue(options))}`,
103+
() => {
104+
const q = toValue(query)
105+
if (!q.trim()) {
106+
return Promise.resolve({ objects: [], total: 0, time: new Date().toISOString() } as NpmSearchResponse)
107+
}
108+
return registry.searchPackages(q, toValue(options))
109+
},
110+
{ watch: [() => toValue(query), () => toValue(options)] },
111+
)
112+
}

app/pages/index.vue

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,55 @@
1+
<script setup lang="ts">
2+
const router = useRouter()
3+
const searchQuery = ref('')
4+
5+
function handleSearch() {
6+
if (searchQuery.value.trim()) {
7+
router.push({ path: '/search', query: { q: searchQuery.value.trim() } })
8+
}
9+
}
10+
11+
useSeoMeta({
12+
title: 'npmx - Better npm Package Browser',
13+
description: 'A fast, accessible npm package browser for power users. Search, browse, and manage npm packages with a modern interface.',
14+
})
15+
</script>
16+
117
<template>
2-
<div>
3-
Nuxt site template
4-
<NuxtLink to="/">
5-
Home
6-
</NuxtLink>
7-
</div>
18+
<main>
19+
<header>
20+
<h1>npmx</h1>
21+
<p>A better npm package browser</p>
22+
</header>
23+
24+
<search>
25+
<form
26+
role="search"
27+
@submit.prevent="handleSearch"
28+
>
29+
<label for="home-search">Search npm packages</label>
30+
<input
31+
id="home-search"
32+
v-model="searchQuery"
33+
type="search"
34+
name="q"
35+
placeholder="Search packages..."
36+
autocomplete="off"
37+
autofocus
38+
>
39+
<button type="submit">
40+
Search
41+
</button>
42+
</form>
43+
</search>
44+
45+
<nav aria-label="Quick links">
46+
<ul>
47+
<li>
48+
<NuxtLink to="/search">
49+
Browse all packages
50+
</NuxtLink>
51+
</li>
52+
</ul>
53+
</nav>
54+
</main>
855
</template>

0 commit comments

Comments
 (0)