Skip to content

Commit 0fbd7f9

Browse files
committed
feat: add username page
1 parent fd6cbf0 commit 0fbd7f9

2 files changed

Lines changed: 195 additions & 3 deletions

File tree

app/pages/package/[...name].vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -552,14 +552,14 @@ defineOgImageComponent('Package', {
552552
v-for="maintainer in pkg.maintainers.slice(0, 5)"
553553
:key="maintainer.name ?? maintainer.email"
554554
>
555-
<a
555+
<NuxtLink
556556
v-if="maintainer.name"
557-
:href="`https://www.npmjs.com/~${maintainer.name}`"
557+
:to="{ name: '~username', params: { username: maintainer.name } }"
558558
rel="noopener noreferrer"
559559
class="link-subtle font-mono text-sm"
560560
>
561561
@{{ maintainer.name }}
562-
</a>
562+
</NuxtLink>
563563
<span
564564
v-else
565565
class="font-mono text-sm text-fg-muted"

app/pages/~[username].vue

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<script setup lang="ts">
2+
const route = useRoute('~username')
3+
4+
const username = computed(() => route.params.username)
5+
6+
// Search for packages by this maintainer
7+
const searchQuery = computed(() => `maintainer:${username.value}`)
8+
const searchOptions = computed(() => ({ size: 250 }))
9+
10+
const { data: results, status, error } = useNpmSearch(searchQuery, searchOptions)
11+
12+
// Sort packages by downloads/popularity (searchScore is a good proxy)
13+
const sortedPackages = computed(() => {
14+
if (!results.value?.objects) return []
15+
return [...results.value.objects].sort((a, b) => b.searchScore - a.searchScore)
16+
})
17+
18+
function formatNumber(num: number): string {
19+
return new Intl.NumberFormat('en-US').format(num)
20+
}
21+
22+
function formatDate(dateStr: string): string {
23+
return new Date(dateStr).toLocaleDateString('en-US', {
24+
year: 'numeric',
25+
month: 'short',
26+
day: 'numeric',
27+
})
28+
}
29+
30+
useSeoMeta({
31+
title: () => `@${username.value} - npmx`,
32+
description: () => `npm packages maintained by ${username.value}`,
33+
})
34+
35+
defineOgImageComponent('Default', {
36+
title: () => `@${username.value}`,
37+
description: () => results.value ? `${results.value.total} packages` : 'npm user profile',
38+
})
39+
</script>
40+
41+
<template>
42+
<main class="container py-8 sm:py-12">
43+
<!-- Header -->
44+
<header class="mb-8 pb-8 border-b border-border">
45+
<div class="flex items-center gap-4 mb-4">
46+
<!-- Avatar placeholder -->
47+
<div
48+
class="w-16 h-16 rounded-full bg-bg-muted border border-border flex items-center justify-center"
49+
aria-hidden="true"
50+
>
51+
<span class="text-2xl text-fg-subtle font-mono">{{ username.charAt(0).toUpperCase() }}</span>
52+
</div>
53+
<div>
54+
<h1 class="font-mono text-2xl sm:text-3xl font-medium">
55+
@{{ username }}
56+
</h1>
57+
<p
58+
v-if="results?.total"
59+
class="text-fg-muted text-sm mt-1"
60+
>
61+
{{ formatNumber(results.total) }} public package{{ results.total === 1 ? '' : 's' }}
62+
</p>
63+
</div>
64+
</div>
65+
66+
<!-- Link to npmjs.com profile -->
67+
<nav aria-label="External links">
68+
<a
69+
:href="`https://www.npmjs.com/~${username}`"
70+
target="_blank"
71+
rel="noopener noreferrer"
72+
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
73+
>
74+
<span class="i-carbon-cube w-4 h-4" />
75+
view on npm
76+
</a>
77+
</nav>
78+
</header>
79+
80+
<!-- Loading state -->
81+
<div
82+
v-if="status === 'pending'"
83+
aria-busy="true"
84+
class="flex items-center gap-3 text-fg-muted font-mono text-sm py-8"
85+
>
86+
<span class="w-4 h-4 border-2 border-fg-subtle border-t-fg rounded-full animate-spin" />
87+
Loading packages...
88+
</div>
89+
90+
<!-- Error state -->
91+
<div
92+
v-else-if="status === 'error'"
93+
role="alert"
94+
class="py-12 text-center"
95+
>
96+
<p class="text-fg-muted mb-4">
97+
{{ error?.message ?? 'Failed to load user packages' }}
98+
</p>
99+
<NuxtLink
100+
to="/"
101+
class="btn"
102+
>
103+
Go back home
104+
</NuxtLink>
105+
</div>
106+
107+
<!-- Empty state -->
108+
<div
109+
v-else-if="results && results.total === 0"
110+
class="py-12 text-center"
111+
>
112+
<p class="text-fg-muted font-mono">
113+
No public packages found for <span class="text-fg">@{{ username }}</span>
114+
</p>
115+
<p class="text-fg-subtle text-sm mt-2">
116+
This user may not exist or has no public packages.
117+
</p>
118+
</div>
119+
120+
<!-- Package list -->
121+
<section
122+
v-else-if="results && sortedPackages.length > 0"
123+
aria-label="User packages"
124+
>
125+
<h2 class="text-xs text-fg-subtle uppercase tracking-wider mb-4">
126+
Packages
127+
</h2>
128+
129+
<ol class="space-y-3 list-none m-0 p-0">
130+
<li
131+
v-for="(result, index) in sortedPackages"
132+
:key="result.package.name"
133+
class="animate-fade-in animate-fill-both"
134+
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
135+
>
136+
<article class="group card-interactive">
137+
<NuxtLink
138+
:to="`/package/${result.package.name}`"
139+
class="block focus:outline-none decoration-none"
140+
>
141+
<header class="flex items-start justify-between gap-4 mb-2">
142+
<h3 class="font-mono text-base font-medium text-fg group-hover:text-fg transition-colors duration-200 min-w-0 break-all">
143+
{{ result.package.name }}
144+
</h3>
145+
<span
146+
v-if="result.package.version"
147+
class="font-mono text-xs text-fg-subtle shrink-0"
148+
>
149+
v{{ result.package.version }}
150+
</span>
151+
</header>
152+
153+
<p
154+
v-if="result.package.description"
155+
class="text-fg-muted text-sm line-clamp-2 mb-3"
156+
>
157+
<MarkdownText :text="result.package.description" />
158+
</p>
159+
160+
<footer class="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-fg-subtle">
161+
<time
162+
v-if="result.package.date"
163+
:datetime="result.package.date"
164+
>
165+
{{ formatDate(result.package.date) }}
166+
</time>
167+
</footer>
168+
</NuxtLink>
169+
170+
<ul
171+
v-if="result.package.keywords?.length"
172+
aria-label="Keywords"
173+
class="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-border list-none m-0 p-0"
174+
>
175+
<li
176+
v-for="keyword in result.package.keywords.slice(0, 5)"
177+
:key="keyword"
178+
>
179+
<NuxtLink
180+
:to="`/search?q=keywords:${encodeURIComponent(keyword)}`"
181+
class="tag decoration-none"
182+
>
183+
{{ keyword }}
184+
</NuxtLink>
185+
</li>
186+
</ul>
187+
</article>
188+
</li>
189+
</ol>
190+
</section>
191+
</main>
192+
</template>

0 commit comments

Comments
 (0)