Skip to content

Commit ec97dc6

Browse files
committed
feat: add basic org page + refactor shared components
1 parent 9fe53bd commit ec97dc6

7 files changed

Lines changed: 305 additions & 193 deletions

File tree

app/components/LoadingSpinner.vue

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script setup lang="ts">
2+
defineProps<{
3+
/** Text to display next to the spinner */
4+
text?: string
5+
}>()
6+
</script>
7+
8+
<template>
9+
<div
10+
aria-busy="true"
11+
class="flex items-center gap-3 text-fg-muted font-mono text-sm py-8"
12+
>
13+
<span class="w-4 h-4 border-2 border-fg-subtle border-t-fg rounded-full animate-spin" />
14+
{{ text ?? 'Loading...' }}
15+
</div>
16+
</template>

app/components/PackageCard.vue

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<script setup lang="ts">
2+
import type { NpmSearchResult } from '#shared/types'
3+
4+
defineProps<{
5+
/** The search result object containing package data */
6+
result: NpmSearchResult
7+
/** Heading level for the package name (h2 for search, h3 for lists) */
8+
headingLevel?: 'h2' | 'h3'
9+
/** Whether to show the publisher username */
10+
showPublisher?: boolean
11+
}>()
12+
13+
function formatDate(dateStr: string): string {
14+
return new Date(dateStr).toLocaleDateString('en-US', {
15+
year: 'numeric',
16+
month: 'short',
17+
day: 'numeric',
18+
})
19+
}
20+
</script>
21+
22+
<template>
23+
<article class="group card-interactive">
24+
<NuxtLink
25+
:to="`/package/${result.package.name}`"
26+
class="block focus:outline-none decoration-none"
27+
>
28+
<header class="flex items-start justify-between gap-4 mb-2">
29+
<component
30+
:is="headingLevel ?? 'h3'"
31+
class="font-mono text-base font-medium text-fg group-hover:text-fg transition-colors duration-200 min-w-0 break-all"
32+
>
33+
{{ result.package.name }}
34+
</component>
35+
<div class="flex items-center gap-1.5 shrink-0">
36+
<span
37+
v-if="result.package.version"
38+
class="font-mono text-xs text-fg-subtle"
39+
>
40+
v{{ result.package.version }}
41+
</span>
42+
<ProvenanceBadge
43+
v-if="result.package.publisher?.trustedPublisher"
44+
:provider="result.package.publisher.trustedPublisher.id"
45+
:package-name="result.package.name"
46+
:version="result.package.version"
47+
compact
48+
/>
49+
</div>
50+
</header>
51+
52+
<p
53+
v-if="result.package.description"
54+
class="text-fg-muted text-sm line-clamp-2 mb-3"
55+
>
56+
<MarkdownText :text="result.package.description" />
57+
</p>
58+
59+
<footer class="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-fg-subtle">
60+
<dl
61+
v-if="showPublisher || result.package.date"
62+
class="flex items-center gap-4 m-0"
63+
>
64+
<div
65+
v-if="showPublisher && result.package.publisher?.username"
66+
class="flex items-center gap-1.5"
67+
>
68+
<dt class="sr-only">
69+
Publisher
70+
</dt>
71+
<dd class="font-mono">
72+
@{{ result.package.publisher.username }}
73+
</dd>
74+
</div>
75+
<div
76+
v-if="result.package.date"
77+
class="flex items-center gap-1.5"
78+
>
79+
<dt class="sr-only">
80+
Updated
81+
</dt>
82+
<dd>
83+
<time :datetime="result.package.date">{{ formatDate(result.package.date) }}</time>
84+
</dd>
85+
</div>
86+
</dl>
87+
</footer>
88+
</NuxtLink>
89+
90+
<ul
91+
v-if="result.package.keywords?.length"
92+
aria-label="Keywords"
93+
class="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-border list-none m-0 p-0"
94+
>
95+
<li
96+
v-for="keyword in result.package.keywords.slice(0, 5)"
97+
:key="keyword"
98+
>
99+
<NuxtLink
100+
:to="`/search?q=keywords:${encodeURIComponent(keyword)}`"
101+
class="tag decoration-none"
102+
>
103+
{{ keyword }}
104+
</NuxtLink>
105+
</li>
106+
</ul>
107+
</article>
108+
</template>

app/components/PackageList.vue

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<script setup lang="ts">
2+
import type { NpmSearchResult } from '#shared/types'
3+
4+
defineProps<{
5+
/** List of search results to display */
6+
results: NpmSearchResult[]
7+
/** Heading level for package names */
8+
headingLevel?: 'h2' | 'h3'
9+
/** Whether to show publisher username on cards */
10+
showPublisher?: boolean
11+
}>()
12+
</script>
13+
14+
<template>
15+
<ol class="space-y-3 list-none m-0 p-0">
16+
<li
17+
v-for="(result, index) in results"
18+
:key="result.package.name"
19+
class="animate-fade-in animate-fill-both"
20+
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
21+
>
22+
<PackageCard
23+
:result="result"
24+
:heading-level="headingLevel"
25+
:show-publisher="showPublisher"
26+
/>
27+
</li>
28+
</ol>
29+
</template>

app/pages/org/[name].vue

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<script setup lang="ts">
2+
import { formatNumber } from '#imports'
3+
4+
const route = useRoute('org-name')
5+
6+
const orgName = computed(() => route.params.name)
7+
8+
// Search for packages in this org's scope (@orgname/*)
9+
const searchQuery = computed(() => `@${orgName.value}`)
10+
11+
const { data: results, status, error } = useNpmSearch(searchQuery, { size: 250 })
12+
13+
// Filter to only include packages that are actually in this scope
14+
// (search may return packages that just mention the org name)
15+
const scopedPackages = computed(() => {
16+
if (!results.value?.objects) return []
17+
const scopePrefix = `@${orgName.value}/`
18+
return results.value.objects
19+
.filter(obj => obj.package.name.startsWith(scopePrefix))
20+
.sort((a, b) => b.searchScore - a.searchScore)
21+
})
22+
23+
const packageCount = computed(() => scopedPackages.value.length)
24+
25+
useSeoMeta({
26+
title: () => `@${orgName.value} - npmx`,
27+
description: () => `npm packages published by the ${orgName.value} organization`,
28+
})
29+
30+
defineOgImageComponent('Default', {
31+
title: () => `@${orgName.value}`,
32+
description: () => scopedPackages.value.length ? `${scopedPackages.value.length} packages` : 'npm organization',
33+
})
34+
</script>
35+
36+
<template>
37+
<main class="container py-8 sm:py-12">
38+
<!-- Header -->
39+
<header class="mb-8 pb-8 border-b border-border">
40+
<div class="flex items-center gap-4 mb-4">
41+
<!-- Org avatar placeholder -->
42+
<div
43+
class="w-16 h-16 rounded-lg bg-bg-muted border border-border flex items-center justify-center"
44+
aria-hidden="true"
45+
>
46+
<span class="text-2xl text-fg-subtle font-mono">{{ orgName.charAt(0).toUpperCase() }}</span>
47+
</div>
48+
<div>
49+
<h1 class="font-mono text-2xl sm:text-3xl font-medium">
50+
@{{ orgName }}
51+
</h1>
52+
<p
53+
v-if="status === 'success'"
54+
class="text-fg-muted text-sm mt-1"
55+
>
56+
{{ formatNumber(packageCount) }} public package{{ packageCount === 1 ? '' : 's' }}
57+
</p>
58+
</div>
59+
</div>
60+
61+
<!-- Link to npmjs.com org page -->
62+
<nav aria-label="External links">
63+
<a
64+
:href="`https://www.npmjs.com/org/${orgName}`"
65+
target="_blank"
66+
rel="noopener noreferrer"
67+
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
68+
>
69+
<span class="i-carbon-cube w-4 h-4" />
70+
view on npm
71+
</a>
72+
</nav>
73+
</header>
74+
75+
<!-- Loading state -->
76+
<LoadingSpinner
77+
v-if="status === 'pending'"
78+
text="Loading packages..."
79+
/>
80+
81+
<!-- Error state -->
82+
<div
83+
v-else-if="status === 'error'"
84+
role="alert"
85+
class="py-12 text-center"
86+
>
87+
<p class="text-fg-muted mb-4">
88+
{{ error?.message ?? 'Failed to load organization packages' }}
89+
</p>
90+
<NuxtLink
91+
to="/"
92+
class="btn"
93+
>
94+
Go back home
95+
</NuxtLink>
96+
</div>
97+
98+
<!-- Empty state -->
99+
<div
100+
v-else-if="packageCount === 0"
101+
class="py-12 text-center"
102+
>
103+
<p class="text-fg-muted font-mono">
104+
No public packages found for <span class="text-fg">@{{ orgName }}</span>
105+
</p>
106+
<p class="text-fg-subtle text-sm mt-2">
107+
This organization may not exist or has no public packages.
108+
</p>
109+
</div>
110+
111+
<!-- Package list -->
112+
<section
113+
v-else-if="scopedPackages.length > 0"
114+
aria-label="Organization packages"
115+
>
116+
<h2 class="text-xs text-fg-subtle uppercase tracking-wider mb-4">
117+
Packages
118+
</h2>
119+
120+
<PackageList :results="scopedPackages" />
121+
</section>
122+
</main>
123+
</template>

0 commit comments

Comments
 (0)