Skip to content

Commit 973ac3f

Browse files
committed
feat: polish package search and org packages UX
- Add relevance sort option for search page and disable other options for now - Hide filters on search page for now - Fix search result "Updated" date to actually work... and respect 'relative' pref... and respect i18n - Add "all" page size option with YOLO label - Table view now forces pagination mode and card view forces infinite scroll - Show appropriate count display per mode: - Infinite: "X packages" or "Found X packages" - Paginated: "X of Y packages"
1 parent 4d8bcea commit 973ac3f

14 files changed

Lines changed: 231 additions & 76 deletions

app/components/PackageList.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,21 @@ const listRef = useTemplateRef<WindowVirtualizerHandle>('listRef')
5858
// View mode and columns
5959
const viewMode = computed(() => props.viewMode ?? 'cards')
6060
const columns = computed(() => props.columns ?? DEFAULT_COLUMNS)
61-
const paginationMode = computed(() => props.paginationMode ?? 'infinite')
61+
// Table view forces pagination mode (no virtualization for tables)
62+
const paginationMode = computed(() =>
63+
viewMode.value === 'table' ? 'paginated' : (props.paginationMode ?? 'infinite'),
64+
)
6265
const currentPage = computed(() => props.currentPage ?? 1)
6366
6467
// Compute paginated results for paginated mode
6568
const displayedResults = computed(() => {
6669
if (paginationMode.value === 'infinite') {
6770
return props.results
6871
}
72+
// 'all' page size means show everything (YOLO)
73+
if (pageSize.value === 'all') {
74+
return props.results
75+
}
6976
const start = (currentPage.value - 1) * pageSize.value
7077
const end = start + pageSize.value
7178
return props.results.slice(start, end)

app/components/PackageListToolbar.vue

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ const props = defineProps<{
3232
filteredCount: number
3333
availableKeywords?: string[]
3434
activeFilters: FilterChip[]
35+
/** When true, shows search-specific UI (relevance sort, no filters) */
36+
searchContext?: boolean
3537
}>()
3638
3739
const emit = defineEmits<{
@@ -62,6 +64,21 @@ const showingFiltered = computed(() => props.filteredCount !== props.totalCount)
6264
// Parse current sort option into key and direction
6365
const currentSort = computed(() => parseSortOption(props.sortOption))
6466
67+
// Get available sort keys based on context
68+
const availableSortKeys = computed(() => {
69+
if (props.searchContext) {
70+
// In search context: show relevance (enabled) and others (disabled)
71+
return SORT_KEYS.filter(k => !k.searchOnly || k.key === 'relevance').map(k =>
72+
Object.assign({}, k, {
73+
disabled: k.key !== 'relevance',
74+
disabledReason: k.key !== 'relevance' ? 'Coming soon' : undefined,
75+
}),
76+
)
77+
}
78+
// In org/user context: hide search-only sorts
79+
return SORT_KEYS.filter(k => !k.searchOnly)
80+
})
81+
6582
// Handle sort key change from dropdown
6683
function handleSortKeyChange(event: Event) {
6784
const target = event.target as HTMLSelectElement
@@ -79,6 +96,7 @@ function handleToggleDirection() {
7996
8097
// Map sort key to i18n key
8198
const sortKeyLabelKeys: Record<SortKey, string> = {
99+
'relevance': 'filters.sort.relevance',
82100
'downloads-week': 'filters.sort.downloads_week',
83101
'downloads-day': 'filters.sort.downloads_day',
84102
'downloads-month': 'filters.sort.downloads_month',
@@ -100,8 +118,11 @@ function getSortKeyLabelKey(key: SortKey): string {
100118
<div class="space-y-3 mb-6">
101119
<!-- Main toolbar row -->
102120
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
103-
<!-- Count display -->
104-
<div class="text-sm font-mono text-fg-muted">
121+
<!-- Count display (infinite scroll mode only) -->
122+
<div
123+
v-if="viewMode === 'cards' && paginationMode === 'infinite' && !searchContext"
124+
class="text-sm font-mono text-fg-muted"
125+
>
105126
<template v-if="showingFiltered">
106127
{{
107128
$t('filters.count.showing_filtered', {
@@ -115,6 +136,19 @@ function getSortKeyLabelKey(key: SortKey): string {
115136
</template>
116137
</div>
117138

139+
<!-- Count display (paginated/table mode only) -->
140+
<div
141+
v-if="(viewMode === 'table' || paginationMode === 'paginated') && !searchContext"
142+
class="text-sm font-mono text-fg-muted"
143+
>
144+
{{
145+
$t('filters.count.showing_paginated', {
146+
pageSize: pageSize === 'all' ? filteredCount : pageSize,
147+
total: filteredCount.toLocaleString(),
148+
})
149+
}}
150+
</div>
151+
118152
<div class="flex-1" />
119153

120154
<div class="flex flex-wrap items-center gap-3">
@@ -138,7 +172,7 @@ function getSortKeyLabelKey(key: SortKey): string {
138172
@change="handleSortKeyChange"
139173
>
140174
<option
141-
v-for="keyConfig in SORT_KEYS"
175+
v-for="keyConfig in availableSortKeys"
142176
:key="keyConfig.key"
143177
:value="keyConfig.key"
144178
:disabled="keyConfig.disabled"
@@ -184,8 +218,9 @@ function getSortKeyLabelKey(key: SortKey): string {
184218
</div>
185219
</div>
186220

187-
<!-- Filter panel -->
221+
<!-- Filter panel (hidden in search context) -->
188222
<FilterPanel
223+
v-if="!searchContext"
189224
:filters="filters"
190225
:available-keywords="availableKeywords"
191226
@update:text="emit('update:text', $event)"
@@ -196,8 +231,9 @@ function getSortKeyLabelKey(key: SortKey): string {
196231
@toggle-keyword="emit('toggleKeyword', $event)"
197232
/>
198233

199-
<!-- Active filter chips -->
234+
<!-- Active filter chips (hidden in search context) -->
200235
<FilterChips
236+
v-if="!searchContext"
201237
:chips="activeFilters"
202238
@remove="emit('clearFilter', $event)"
203239
@clear-all="emit('clearAllFilters')"

app/components/PackageTableRow.vue

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,37 +17,16 @@ const emit = defineEmits<{
1717
const pkg = computed(() => props.result.package)
1818
const score = computed(() => props.result.score)
1919
20+
// Get the best available date: prefer result.updated (from packument), fall back to package.date
21+
const updatedDate = computed(() => props.result.updated ?? props.result.package.date)
22+
2023
function formatDownloads(count?: number): string {
2124
if (count === undefined) return '-'
2225
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
2326
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`
2427
return count.toString()
2528
}
2629
27-
function formatDate(dateStr?: string): string {
28-
if (!dateStr) return '-'
29-
const date = new Date(dateStr)
30-
if (Number.isNaN(date.getTime())) return '-'
31-
const now = new Date()
32-
const diffMs = now.getTime() - date.getTime()
33-
const diffSeconds = Math.floor(diffMs / 1000)
34-
const diffMinutes = Math.floor(diffSeconds / 60)
35-
const diffHours = Math.floor(diffMinutes / 60)
36-
const diffDays = Math.floor(diffHours / 24)
37-
const diffWeeks = Math.floor(diffDays / 7)
38-
const diffMonths = Math.floor(diffDays / 30)
39-
const diffYears = Math.floor(diffDays / 365)
40-
41-
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
42-
43-
if (diffDays === 0) return rtf.format(0, 'day')
44-
if (diffDays === 1) return rtf.format(-1, 'day')
45-
if (diffDays < 7) return rtf.format(-diffDays, 'day')
46-
if (diffDays < 30) return rtf.format(-diffWeeks, 'week')
47-
if (diffDays < 365) return rtf.format(-diffMonths, 'month')
48-
return rtf.format(-diffYears, 'year')
49-
}
50-
5130
function formatScore(value?: number): string {
5231
if (value === undefined || value === 0) return '-'
5332
return Math.round(value * 100).toString()
@@ -105,7 +84,14 @@ const allMaintainersText = computed(() => {
10584

10685
<!-- Updated -->
10786
<td v-if="isColumnVisible('updated')" class="py-2 px-3 font-mono text-xs text-fg-muted">
108-
{{ formatDate(result.updated ?? pkg.date) }}
87+
<DateTime
88+
v-if="updatedDate"
89+
:datetime="updatedDate"
90+
year="numeric"
91+
month="short"
92+
day="numeric"
93+
/>
94+
<span v-else>-</span>
10995
</td>
11096

11197
<!-- Maintainers -->

app/components/PaginationControls.vue

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,50 @@
11
<script setup lang="ts">
2-
import type { PageSize, PaginationMode } from '#shared/types/preferences'
2+
import type { PageSize, PaginationMode, ViewMode } from '#shared/types/preferences'
33
import { PAGE_SIZE_OPTIONS } from '#shared/types/preferences'
44
55
const props = defineProps<{
66
mode: PaginationMode
77
pageSize: PageSize
88
currentPage: number
99
totalItems: number
10+
/** When in table view, force pagination mode (no infinite scroll for tables) */
11+
viewMode?: ViewMode
1012
}>()
1113
14+
// Whether we should show pagination controls (table view always uses pagination)
15+
const shouldShowControls = computed(() => props.viewMode === 'table' || props.mode === 'paginated')
16+
17+
// Table view forces pagination mode, otherwise use the provided mode
18+
const effectiveMode = computed<PaginationMode>(() =>
19+
shouldShowControls.value ? 'paginated' : 'infinite',
20+
)
21+
1222
const emit = defineEmits<{
1323
'update:mode': [mode: PaginationMode]
1424
'update:pageSize': [size: PageSize]
1525
'update:currentPage': [page: number]
1626
}>()
1727
18-
const totalPages = computed(() => Math.ceil(props.totalItems / props.pageSize))
19-
20-
const startItem = computed(() =>
21-
props.totalItems === 0 ? 0 : (props.currentPage - 1) * props.pageSize + 1,
28+
// When 'all' is selected, there's only 1 page with everything
29+
const isShowingAll = computed(() => props.pageSize === 'all')
30+
const effectivePageSize = computed(() => (isShowingAll.value ? props.totalItems : props.pageSize))
31+
const totalPages = computed(() =>
32+
isShowingAll.value ? 1 : Math.ceil(props.totalItems / (props.pageSize as number)),
2233
)
2334
24-
const endItem = computed(() => Math.min(props.currentPage * props.pageSize, props.totalItems))
35+
// Whether to show the mode toggle (hidden in table view since table always uses pagination)
36+
const showModeToggle = computed(() => props.viewMode !== 'table')
37+
38+
const startItem = computed(() => {
39+
if (props.totalItems === 0) return 0
40+
if (isShowingAll.value) return 1
41+
return (props.currentPage - 1) * (props.pageSize as number) + 1
42+
})
43+
44+
const endItem = computed(() => {
45+
if (isShowingAll.value) return props.totalItems
46+
return Math.min(props.currentPage * (props.pageSize as number), props.totalItems)
47+
})
2548
2649
const canGoPrev = computed(() => props.currentPage > 1)
2750
const canGoNext = computed(() => props.currentPage < totalPages.value)
@@ -86,19 +109,26 @@ const visiblePages = computed(() => {
86109
87110
function handlePageSizeChange(event: Event) {
88111
const target = event.target as HTMLSelectElement
89-
const newSize = Number(target.value) as PageSize
112+
const value = target.value
113+
// Handle 'all' as a special string value, otherwise parse as number
114+
const newSize = (value === 'all' ? 'all' : Number(value)) as PageSize
90115
emit('update:pageSize', newSize)
91116
// Reset to page 1 when changing page size
92117
emit('update:currentPage', 1)
93118
}
94119
</script>
95120

96121
<template>
97-
<div class="flex flex-wrap items-center justify-between gap-4 py-4 border-t border-border mt-6">
122+
<!-- Only show when in paginated mode (table view or explicit paginated mode) -->
123+
<div
124+
v-if="shouldShowControls"
125+
class="flex flex-wrap items-center justify-between gap-4 py-4 border-t border-border mt-6"
126+
>
98127
<!-- Left: Mode toggle and page size -->
99128
<div class="flex items-center gap-4">
100-
<!-- Pagination mode toggle -->
129+
<!-- Pagination mode toggle (hidden in table view - tables always use pagination) -->
101130
<div
131+
v-if="showModeToggle"
102132
class="inline-flex rounded-md border border-border p-0.5 bg-bg-subtle"
103133
role="group"
104134
:aria-label="$t('filters.pagination.mode_label')"
@@ -123,8 +153,8 @@ function handlePageSizeChange(event: Event) {
123153
</button>
124154
</div>
125155

126-
<!-- Page size (paginated mode only) -->
127-
<div v-if="mode === 'paginated'" class="relative shrink-0">
156+
<!-- Page size (shown when paginated or table view) -->
157+
<div v-if="effectiveMode === 'paginated'" class="relative shrink-0">
128158
<label for="page-size" class="sr-only">{{ $t('filters.pagination.items_per_page') }}</label>
129159
<select
130160
id="page-size"
@@ -133,7 +163,11 @@ function handlePageSizeChange(event: Event) {
133163
@change="handlePageSizeChange"
134164
>
135165
<option v-for="size in PAGE_SIZE_OPTIONS" :key="size" :value="size">
136-
{{ size }}
166+
{{
167+
size === 'all'
168+
? $t('filters.pagination.all_yolo')
169+
: $t('filters.pagination.per_page', { count: size })
170+
}}
137171
</option>
138172
</select>
139173
<div
@@ -146,7 +180,7 @@ function handlePageSizeChange(event: Event) {
146180
</div>
147181

148182
<!-- Right: Page info and navigation (paginated mode only) -->
149-
<div v-if="mode === 'paginated' && totalItems > 0" class="flex items-center gap-4">
183+
<div v-if="effectiveMode === 'paginated'" class="flex items-center gap-4">
150184
<!-- Showing X-Y of Z -->
151185
<span class="text-sm font-mono text-fg-muted">
152186
{{

app/composables/usePackageListPreferences.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export function usePackageListPreferences() {
7777
}
7878

7979
function resetColumns() {
80-
preferences.value.columns = DEFAULT_COLUMNS.map(col => ({ ...col }))
80+
preferences.value.columns = DEFAULT_COLUMNS.map(col => Object.assign({}, col))
8181
save()
8282
}
8383

app/composables/useStructuredFilters.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,22 @@ interface UseStructuredFiltersOptions {
114114
initialSort?: SortOption
115115
}
116116

117+
// Pure filter predicates (no closure dependencies)
118+
function matchesKeywords(pkg: NpmSearchResult, keywords: string[]): boolean {
119+
if (keywords.length === 0) return true
120+
const pkgKeywords = new Set((pkg.package.keywords ?? []).map(k => k.toLowerCase()))
121+
// AND logic: package must have ALL selected keywords (case-insensitive)
122+
return keywords.every(k => pkgKeywords.has(k.toLowerCase()))
123+
}
124+
125+
function matchesSecurity(pkg: NpmSearchResult, security: SecurityFilter): boolean {
126+
if (security === 'all') return true
127+
const hasWarnings = (pkg.flags?.insecure ?? 0) > 0
128+
if (security === 'secure') return !hasWarnings
129+
if (security === 'warnings') return hasWarnings
130+
return true
131+
}
132+
117133
/**
118134
* Composable for structured filtering and sorting of package lists
119135
*/
@@ -224,21 +240,6 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
224240
return true
225241
}
226242

227-
function matchesKeywords(pkg: NpmSearchResult, keywords: string[]): boolean {
228-
if (keywords.length === 0) return true
229-
const pkgKeywords = new Set((pkg.package.keywords ?? []).map(k => k.toLowerCase()))
230-
// AND logic: package must have ALL selected keywords (case-insensitive)
231-
return keywords.every(k => pkgKeywords.has(k.toLowerCase()))
232-
}
233-
234-
function matchesSecurity(pkg: NpmSearchResult, security: SecurityFilter): boolean {
235-
if (security === 'all') return true
236-
const hasWarnings = (pkg.flags?.insecure ?? 0) > 0
237-
if (security === 'secure') return !hasWarnings
238-
if (security === 'warnings') return hasWarnings
239-
return true
240-
}
241-
242243
function matchesUpdatedWithin(pkg: NpmSearchResult, within: UpdatedWithin): boolean {
243244
if (within === 'any') return true
244245
const config = UPDATED_WITHIN_OPTIONS.find(o => o.value === within)
@@ -298,6 +299,10 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
298299
case 'score':
299300
diff = (a.score?.final ?? 0) - (b.score?.final ?? 0)
300301
break
302+
case 'relevance':
303+
// Relevance preserves server order (already sorted by search relevance)
304+
diff = 0
305+
break
301306
default:
302307
diff = 0
303308
}

app/pages/@[org].vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ defineOgImageComponent('Default', {
271271
:page-size="pageSize"
272272
:current-page="currentPage"
273273
:total-items="sortedPackages.length"
274+
:view-mode="viewMode"
274275
@update:mode="paginationMode = $event"
275276
@update:page-size="pageSize = $event"
276277
@update:current-page="currentPage = $event"

0 commit comments

Comments
 (0)