Skip to content

Commit 7ff25eb

Browse files
authored
Merge branch 'main' into feat/extract-playgrounds
2 parents f81ba0b + 2a5ed7d commit 7ff25eb

22 files changed

Lines changed: 1873 additions & 298 deletions

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: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
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>
@@ -7,6 +10,23 @@ const props = defineProps<{
710
optionalDependencies?: Record<string, string>
811
}>()
912
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+
1030
// Expanded state for each section
1131
const depsExpanded = ref(false)
1232
const peerDepsExpanded = ref(false)
@@ -61,11 +81,26 @@ const sortedOptionalDependencies = computed(() => {
6181
>
6282
{{ dep }}
6383
</NuxtLink>
64-
<span
65-
class="font-mono text-xs text-fg-subtle max-w-[50%] text-right truncate"
66-
:title="version"
67-
>
68-
{{ 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>
69104
</span>
70105
</li>
71106
</ul>

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>
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>

0 commit comments

Comments
 (0)