Skip to content

Commit 4ecda20

Browse files
authored
Merge branch 'danielroe:main' into main
2 parents 1cdd78a + 4279ff7 commit 4ecda20

43 files changed

Lines changed: 3260 additions & 764 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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>

app/components/PackageDependencies.vue

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,36 @@
11
<script setup lang="ts">
2+
import { useOutdatedDependencies, getOutdatedTooltip } from '~/composables/useNpmRegistry'
3+
import type { OutdatedDependencyInfo } from '~/composables/useNpmRegistry'
4+
25
const props = defineProps<{
36
packageName: string
47
dependencies?: Record<string, string>
58
peerDependencies?: Record<string, string>
69
peerDependenciesMeta?: Record<string, { optional?: boolean }>
10+
optionalDependencies?: Record<string, string>
711
}>()
812
13+
// Fetch outdated info for dependencies
14+
const outdatedDeps = useOutdatedDependencies(() => props.dependencies)
15+
16+
/**
17+
* Get CSS class for a dependency version based on outdated status
18+
*/
19+
function getVersionClass(info: OutdatedDependencyInfo | undefined): string {
20+
if (!info) return 'text-fg-subtle'
21+
22+
// Red for major versions behind
23+
if (info.majorsBehind > 0) return 'text-red-500 cursor-help'
24+
// Orange for minor versions behind
25+
if (info.minorsBehind > 0) return 'text-orange-500 cursor-help'
26+
// Yellow for patch versions behind
27+
return 'text-yellow-500 cursor-help'
28+
}
29+
930
// Expanded state for each section
1031
const depsExpanded = ref(false)
1132
const peerDepsExpanded = ref(false)
33+
const optionalDepsExpanded = ref(false)
1234
1335
// Sort dependencies alphabetically
1436
const sortedDependencies = computed(() => {
@@ -32,27 +54,21 @@ const sortedPeerDependencies = computed(() => {
3254
return a.name.localeCompare(b.name)
3355
})
3456
})
57+
58+
// Sort optional dependencies alphabetically
59+
const sortedOptionalDependencies = computed(() => {
60+
if (!props.optionalDependencies) return []
61+
return Object.entries(props.optionalDependencies).sort(([a], [b]) => a.localeCompare(b))
62+
})
3563
</script>
3664

3765
<template>
3866
<div class="space-y-8">
3967
<!-- Dependencies -->
4068
<section v-if="sortedDependencies.length > 0" aria-labelledby="dependencies-heading">
41-
<div class="flex items-center justify-between mb-3">
42-
<h2 id="dependencies-heading" class="text-xs text-fg-subtle uppercase tracking-wider">
43-
Dependencies ({{ sortedDependencies.length }})
44-
</h2>
45-
<a
46-
:href="`https://npmgraph.js.org/?q=${packageName}`"
47-
target="_blank"
48-
rel="noopener noreferrer"
49-
class="link-subtle text-fg-subtle"
50-
aria-label="View dependency graph"
51-
title="View dependency graph"
52-
>
53-
<span class="text-xs uppercase tracking-wider"> Graph </span>
54-
</a>
55-
</div>
69+
<h2 id="dependencies-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3">
70+
Dependencies ({{ sortedDependencies.length }})
71+
</h2>
5672
<ul class="space-y-1 list-none m-0 p-0" aria-label="Package dependencies">
5773
<li
5874
v-for="[dep, version] in sortedDependencies.slice(0, depsExpanded ? undefined : 10)"
@@ -65,11 +81,26 @@ const sortedPeerDependencies = computed(() => {
6581
>
6682
{{ dep }}
6783
</NuxtLink>
68-
<span
69-
class="font-mono text-xs text-fg-subtle max-w-[50%] text-right truncate"
70-
:title="version"
71-
>
72-
{{ version }}
84+
<span class="flex items-center gap-1">
85+
<span
86+
v-if="outdatedDeps[dep]"
87+
class="shrink-0"
88+
:class="getVersionClass(outdatedDeps[dep])"
89+
:title="getOutdatedTooltip(outdatedDeps[dep])"
90+
aria-hidden="true"
91+
>
92+
<span class="i-carbon-warning-alt w-3 h-3" />
93+
</span>
94+
<span
95+
class="font-mono text-xs text-right truncate"
96+
:class="getVersionClass(outdatedDeps[dep])"
97+
:title="outdatedDeps[dep] ? getOutdatedTooltip(outdatedDeps[dep]) : version"
98+
>
99+
{{ version }}
100+
</span>
101+
<span v-if="outdatedDeps[dep]" class="sr-only">
102+
({{ getOutdatedTooltip(outdatedDeps[dep]) }})
103+
</span>
73104
</span>
74105
</li>
75106
</ul>
@@ -129,5 +160,49 @@ const sortedPeerDependencies = computed(() => {
129160
show all {{ sortedPeerDependencies.length }} peer deps
130161
</button>
131162
</section>
163+
164+
<!-- Optional Dependencies -->
165+
<section
166+
v-if="sortedOptionalDependencies.length > 0"
167+
aria-labelledby="optional-dependencies-heading"
168+
>
169+
<h2
170+
id="optional-dependencies-heading"
171+
class="text-xs text-fg-subtle uppercase tracking-wider mb-3"
172+
>
173+
Optional Dependencies ({{ sortedOptionalDependencies.length }})
174+
</h2>
175+
<ul class="space-y-1 list-none m-0 p-0" aria-label="Package optional dependencies">
176+
<li
177+
v-for="[dep, version] in sortedOptionalDependencies.slice(
178+
0,
179+
optionalDepsExpanded ? undefined : 10,
180+
)"
181+
:key="dep"
182+
class="flex items-center justify-between py-1 text-sm gap-2"
183+
>
184+
<NuxtLink
185+
:to="{ name: 'package', params: { package: dep.split('/') } }"
186+
class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate min-w-0"
187+
>
188+
{{ dep }}
189+
</NuxtLink>
190+
<span
191+
class="font-mono text-xs text-fg-subtle max-w-[50%] text-right truncate"
192+
:title="version"
193+
>
194+
{{ version }}
195+
</span>
196+
</li>
197+
</ul>
198+
<button
199+
v-if="sortedOptionalDependencies.length > 10 && !optionalDepsExpanded"
200+
type="button"
201+
class="mt-2 font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
202+
@click="optionalDepsExpanded = true"
203+
>
204+
show all {{ sortedOptionalDependencies.length }} optional deps
205+
</button>
206+
</section>
132207
</div>
133208
</template>

app/components/PackageDownloadStats.vue

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,7 @@ const config = computed(() => ({
7575
<!-- Download stats -->
7676
<section>
7777
<div class="flex items-center justify-between mb-3">
78-
<h2 id="dependencies-heading" class="text-xs text-fg-subtle uppercase tracking-wider">
79-
Weekly Downloads
80-
</h2>
78+
<h2 class="text-xs text-fg-subtle uppercase tracking-wider">Weekly Downloads</h2>
8179
</div>
8280
<div class="w-full overflow-hidden">
8381
<ClientOnly>

app/components/PackageList.vue

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,123 @@
11
<script setup lang="ts">
22
import type { NpmSearchResult } from '#shared/types'
3+
import type { WindowVirtualizerHandle } from '~/composables/useVirtualInfiniteScroll'
4+
import { WindowVirtualizer } from 'virtua/vue'
35
4-
defineProps<{
6+
const props = defineProps<{
57
/** List of search results to display */
68
results: NpmSearchResult[]
79
/** Heading level for package names */
810
headingLevel?: 'h2' | 'h3'
911
/** Whether to show publisher username on cards */
1012
showPublisher?: boolean
13+
/** Whether there are more items to load */
14+
hasMore?: boolean
15+
/** Whether currently loading more items */
16+
isLoading?: boolean
17+
/** Page size for tracking current page */
18+
pageSize?: number
19+
/** Initial page to scroll to (1-indexed) */
20+
initialPage?: number
1121
}>()
22+
23+
const emit = defineEmits<{
24+
/** Emitted when scrolled near the bottom and more items should be loaded */
25+
loadMore: []
26+
/** Emitted when the visible page changes */
27+
pageChange: [page: number]
28+
}>()
29+
30+
// Reference to WindowVirtualizer for infinite scroll detection
31+
const listRef = ref<WindowVirtualizerHandle>()
32+
33+
// Set up infinite scroll if hasMore is provided
34+
const hasMore = computed(() => props.hasMore ?? false)
35+
const isLoading = computed(() => props.isLoading ?? false)
36+
const itemCount = computed(() => props.results.length)
37+
const pageSize = computed(() => props.pageSize ?? 20)
38+
39+
const { handleScroll, scrollToPage } = useVirtualInfiniteScroll({
40+
listRef,
41+
itemCount,
42+
hasMore,
43+
isLoading,
44+
pageSize: pageSize.value,
45+
threshold: 5,
46+
onLoadMore: () => emit('loadMore'),
47+
onPageChange: page => emit('pageChange', page),
48+
})
49+
50+
// Scroll to initial page once list is ready and has items
51+
const hasScrolledToInitial = ref(false)
52+
53+
watch(
54+
[() => props.results.length, () => props.initialPage, listRef],
55+
([length, initialPage, list]) => {
56+
if (!hasScrolledToInitial.value && list && length > 0 && initialPage && initialPage > 1) {
57+
// Wait for next tick to ensure list is rendered
58+
nextTick(() => {
59+
scrollToPage(initialPage)
60+
hasScrolledToInitial.value = true
61+
})
62+
}
63+
},
64+
{ immediate: true },
65+
)
66+
67+
// Reset scroll state when results change significantly (new search)
68+
watch(
69+
() => props.results,
70+
(newResults, oldResults) => {
71+
// If this looks like a new search (different first item or much shorter), reset
72+
if (
73+
!oldResults ||
74+
newResults.length === 0 ||
75+
(oldResults.length > 0 && newResults[0]?.package.name !== oldResults[0]?.package.name)
76+
) {
77+
hasScrolledToInitial.value = false
78+
}
79+
},
80+
)
1281
</script>
1382

1483
<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` }"
84+
<div>
85+
<WindowVirtualizer
86+
ref="listRef"
87+
:data="results"
88+
:item-size="140"
89+
as="ol"
90+
item="li"
91+
class="list-none m-0 p-0"
92+
@scroll="handleScroll"
93+
>
94+
<template #default="{ item, index }">
95+
<div class="pb-4">
96+
<PackageCard
97+
:result="item as NpmSearchResult"
98+
:heading-level="headingLevel"
99+
:show-publisher="showPublisher"
100+
class="animate-fade-in animate-fill-both"
101+
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
102+
/>
103+
</div>
104+
</template>
105+
</WindowVirtualizer>
106+
107+
<!-- Loading indicator -->
108+
<div v-if="isLoading" class="py-4 flex items-center justify-center">
109+
<div class="flex items-center gap-3 text-fg-muted font-mono text-sm">
110+
<span class="w-4 h-4 border-2 border-fg-subtle border-t-fg rounded-full animate-spin" />
111+
Loading more...
112+
</div>
113+
</div>
114+
115+
<!-- End of results -->
116+
<p
117+
v-else-if="!hasMore && results.length > 0"
118+
class="py-4 text-center text-fg-subtle font-mono text-sm"
21119
>
22-
<PackageCard :result="result" :heading-level="headingLevel" :show-publisher="showPublisher" />
23-
</li>
24-
</ol>
120+
End of results
121+
</p>
122+
</div>
25123
</template>

0 commit comments

Comments
 (0)