Skip to content

Commit e073a7f

Browse files
feat: add persisted state via url
1 parent 7554b12 commit e073a7f

10 files changed

Lines changed: 144 additions & 48 deletions

File tree

app/components/Package/Card.vue

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ const props = defineProps<{
1616
searchQuery?: string
1717
}>()
1818
19-
const { isPackageSelected, togglePackageSelection } = usePackageSelection()
19+
const { isPackageSelected, togglePackageSelection, isMaxSelected } = usePackageSelection()
2020
const isSelected = computed<boolean>(() => {
21-
return isPackageSelected(props.result)
21+
return isPackageSelected(props.result.package.name)
2222
})
2323
2424
const emit = defineEmits<{
@@ -70,10 +70,12 @@ const numberFormatter = useNumberFormatter()
7070
<span class="sr-only"> {{ $t('package.card.select') }}: {{ result.package.name }} </span>
7171
<input
7272
data-package-card-checkbox
73-
class="md:opacity-0 group-focus-within:opacity-100 checked:opacity-100 md:group-hover:opacity-100 size-4 cursor-pointer accent-accent border border-fg-muted/30 hover:border-accent transition-colors"
73+
class="md:opacity-0 group-focus-within:opacity-100 checked:opacity-100 md:group-hover:opacity-100 size-4 cursor-pointer accent-accent border border-fg-muted/30 hover:border-accent transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
7474
type="checkbox"
7575
:checked="isSelected"
76-
@change="togglePackageSelection(result)"
76+
:disabled="isMaxSelected && !isSelected"
77+
:title="isMaxSelected && !isSelected ? 'Maximum 4 packages can be selected' : undefined"
78+
@change="togglePackageSelection(result.package.name)"
7779
/>
7880
</label>
7981
</div>

app/components/Package/SelectionView.vue

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,43 @@ defineProps<{
33
viewMode?: ViewMode
44
}>()
55
6-
const { selectedPackages, clearSelectedPackages, selectedPackagesParam } = usePackageSelection()
6+
const { selectedPackages, clearSelectedPackages, selectedPackagesParam, closeSelectionView } =
7+
usePackageSelection()
8+
9+
const { data, pending } = useAsyncData(
10+
async () => {
11+
const results = await Promise.all(
12+
selectedPackages.value.map(name =>
13+
$fetch(`/api/registry/package-meta/${encodeURIComponent(name)}`)
14+
.then(response => {
15+
return { package: response }
16+
})
17+
.catch(err => {
18+
console.error(`Failed to fetch package ${name}:`, err)
19+
return null
20+
}),
21+
),
22+
)
23+
return results as NpmSearchResult[]
24+
},
25+
{
26+
default: () => [],
27+
},
28+
)
729
</script>
830

931
<template>
1032
<section>
11-
<header class="mb-6 flex items-center justify-end">
33+
<header class="mb-6 flex items-center justify-between">
34+
<button
35+
type="button"
36+
class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
37+
@click="closeSelectionView"
38+
:aria-label="$t('nav.back')"
39+
>
40+
<span class="i-lucide:arrow-left rtl-flip w-4 h-4" aria-hidden="true" />
41+
<span class="hidden sm:inline">{{ $t('nav.back') }}</span>
42+
</button>
1243
<div class="flex items-center gap-2">
1344
<ButtonBase variant="secondary" @click="clearSelectedPackages">
1445
{{ $t('filters.clear_all') }}
@@ -25,17 +56,22 @@ const { selectedPackages, clearSelectedPackages, selectedPackagesParam } = usePa
2556

2657
<p class="text-fg-muted text-sm font-mono">
2758
{{ $t('action_bar.selection', selectedPackages.length) }}
59+
<span class="text-accent">— Compare up to 4 packages</span>
2860
</p>
2961

3062
<div class="mt-6">
63+
<div v-if="pending" class="flex items-center justify-center py-12">
64+
<LoadingSpinner :text="$t('common.loading')" />
65+
</div>
3166
<PackageList
32-
v-if="selectedPackages.length > 0"
67+
v-else-if="data?.length"
3368
:view-mode="viewMode"
34-
:results="selectedPackages"
35-
search-context
69+
:results="data"
3670
heading-level="h2"
37-
show-publisher
3871
/>
72+
<p v-else class="text-fg-muted text-sm">
73+
{{ $t('filters.table.no_packages') }}
74+
</p>
3975
</div>
4076
</section>
4177
</template>

app/components/Package/TableRow.vue

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ const pkg = computed(() => props.result.package)
1717
const score = computed(() => props.result.score)
1818
1919
const updatedDate = computed(() => props.result.package.date)
20-
const { isPackageSelected, togglePackageSelection } = usePackageSelection()
20+
const { isPackageSelected, togglePackageSelection, isMaxSelected } = usePackageSelection()
2121
const isSelected = computed<boolean>(() => {
22-
return isPackageSelected(props.result)
22+
return isPackageSelected(props.result.package.name)
2323
})
2424
2525
function formatDownloads(count?: number): string {
@@ -209,10 +209,12 @@ const allMaintainersText = computed(() => {
209209
</span>
210210
<input
211211
data-package-card-checkbox
212-
class="md:opacity-0 group-focus-within:opacity-100 checked:opacity-100 md:group-hover:opacity-100 size-4 cursor-pointer accent-accent border border-fg-muted/30 hover:border-accent transition-colors"
212+
class="md:opacity-0 group-focus-within:opacity-100 checked:opacity-100 md:group-hover:opacity-100 size-4 cursor-pointer accent-accent border border-fg-muted/30 hover:border-accent transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
213213
type="checkbox"
214214
:checked="isSelected"
215-
@change="togglePackageSelection(result)"
215+
:disabled="isMaxSelected && !isSelected"
216+
:title="isMaxSelected && !isSelected ? 'Maximum 4 packages can be selected' : undefined"
217+
@change="togglePackageSelection(result.package.name)"
216218
/>
217219
</label>
218220
</div>
Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,85 @@
1+
export const MAX_PACKAGE_SELECTION = 4
2+
13
export function usePackageSelection() {
2-
const selectedPackages = useState<NpmSearchResult[]>('package_selection', () => [])
3-
const selectedPackagesParam = computed<string>(() =>
4-
selectedPackages.value.map(p => p.package.name).join(','),
5-
)
4+
// Use 'push' mode and let router handle scroll behavior naturally
5+
const selectedPackagesParam = useRouteQuery<string>('selection', '', { mode: 'push' })
6+
const showSelectionViewParam = useRouteQuery<string>('view', '', { mode: 'push' })
7+
8+
// Parse URL param into array of package names
9+
const selectedPackages = computed<string[]>({
10+
get() {
11+
const raw = selectedPackagesParam.value
12+
if (!raw) return []
13+
return raw
14+
.split(',')
15+
.map(p => String(p).trim())
16+
.filter(Boolean)
17+
.slice(0, MAX_PACKAGE_SELECTION)
18+
},
19+
set(pkgs: string[]) {
20+
// Ensure all items are strings before joining
21+
const validPkgs = (Array.isArray(pkgs) ? pkgs : []).map(p => String(p).trim()).filter(Boolean)
22+
selectedPackagesParam.value = validPkgs.length > 0 ? validPkgs.join(',') : ''
23+
},
24+
})
25+
26+
// Check if max selection is reached
27+
const isMaxSelected = computed(() => selectedPackages.value.length >= MAX_PACKAGE_SELECTION)
628

7-
function isPackageSelected(pkg: NpmSearchResult): boolean {
8-
return selectedPackages.value.some(p => p.package.name === pkg.package.name)
29+
// Track whether the SelectionView is open/visible
30+
const showSelectionView = computed<boolean>({
31+
get() {
32+
return showSelectionViewParam.value === 'selection'
33+
},
34+
set(isOpen: boolean) {
35+
showSelectionViewParam.value = isOpen ? 'selection' : ''
36+
},
37+
})
38+
39+
/** Check if a package name is selected */
40+
function isPackageSelected(packageName: string): boolean {
41+
return selectedPackages.value.includes(String(packageName).trim())
942
}
1043

11-
function togglePackageSelection(pkg: NpmSearchResult) {
12-
if (isPackageSelected(pkg)) {
13-
selectedPackages.value = selectedPackages.value.filter(
14-
selected => selected.package.name !== pkg.package.name,
15-
)
44+
/** Toggle selection for a package by name */
45+
function togglePackageSelection(packageName: string) {
46+
const safeName = String(packageName).trim()
47+
if (!safeName) return
48+
49+
const pkgs = [...selectedPackages.value]
50+
const idx = pkgs.indexOf(safeName)
51+
if (idx !== -1) {
52+
pkgs.splice(idx, 1)
1653
} else {
17-
selectedPackages.value = [...selectedPackages.value, pkg]
54+
if (pkgs.length < MAX_PACKAGE_SELECTION) pkgs.push(safeName)
1855
}
56+
selectedPackages.value = pkgs
1957
}
2058

59+
/** Clear all selected packages */
2160
function clearSelectedPackages() {
2261
selectedPackages.value = []
2362
}
2463

64+
/** Close the selection view */
65+
function closeSelectionView() {
66+
showSelectionView.value = false
67+
}
68+
69+
/** Open the selection view */
70+
function openSelectionView() {
71+
showSelectionView.value = true
72+
}
73+
2574
return {
2675
selectedPackages,
2776
selectedPackagesParam,
77+
showSelectionView,
78+
isMaxSelected,
2879
clearSelectedPackages,
2980
isPackageSelected,
3081
togglePackageSelection,
82+
closeSelectionView,
83+
openSelectionView,
3184
}
3285
}

app/pages/search.vue

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,15 @@ import { normalizeSearchParam } from '#shared/utils/url'
99
1010
const route = useRoute()
1111
12-
const { selectedPackages } = usePackageSelection()
13-
const isSelectioView = ref<boolean>(false)
12+
const { selectedPackages, showSelectionView, openSelectionView, closeSelectionView } =
13+
usePackageSelection()
1414
1515
watch(selectedPackages, packages => {
1616
if (packages.length === 0) {
17-
isSelectioView.value = false
17+
closeSelectionView()
1818
}
1919
})
2020
21-
function showSelectionView() {
22-
isSelectioView.value = true
23-
}
24-
function hideSelectionView() {
25-
isSelectioView.value = false
26-
}
27-
2821
// Preferences (persisted to localStorage)
2922
const {
3023
viewMode,
@@ -574,7 +567,7 @@ defineOgImageComponent('Default', {
574567
</script>
575568

576569
<template>
577-
<PackageActionBar v-if="!isSelectioView" />
570+
<PackageActionBar v-if="!showSelectionView" />
578571

579572
<main class="flex-1 py-8 search-page" :class="{ 'overflow-x-hidden': viewMode !== 'table' }">
580573
<div class="container-sm">
@@ -583,10 +576,10 @@ defineOgImageComponent('Default', {
583576
{{ $t('search.title') }}
584577
</h1>
585578
<button
586-
v-if="isSelectioView"
579+
v-if="showSelectionView"
587580
type="button"
588581
class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
589-
@click="hideSelectionView"
582+
@click="closeSelectionView"
590583
:aria-label="$t('nav.back')"
591584
>
592585
<span class="i-lucide:arrow-left rtl-flip w-4 h-4" aria-hidden="true" />
@@ -596,8 +589,7 @@ defineOgImageComponent('Default', {
596589
</div>
597590

598591
<PackageSelectionView
599-
@back="hideSelectionView"
600-
v-if="isSelectioView && selectedPackages.length"
592+
v-if="showSelectionView && selectedPackages.length"
601593
:view-mode="viewMode"
602594
/>
603595

@@ -670,7 +662,7 @@ defineOgImageComponent('Default', {
670662
:disabled-sort-keys="disabledSortKeys"
671663
search-context
672664
@toggle-column="toggleColumn"
673-
@toggle-selection="showSelectionView"
665+
@toggle-selection="openSelectionView"
674666
@reset-columns="resetColumns"
675667
@clear-filter="handleClearFilter"
676668
@clear-all-filters="clearAllFilters"

app/router.options.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
import type { RouterConfig } from 'nuxt/schema'
22

33
export default {
4-
scrollBehavior(to, _from, savedPosition) {
4+
scrollBehavior(to, from, savedPosition) {
55
// If the browser has a saved position (e.g. back/forward navigation), restore it
6-
76
if (savedPosition) {
87
return savedPosition
98
}
9+
10+
// If only query parameters changed (same path), don't scroll
11+
if (to.path === from.path) {
12+
return false
13+
}
14+
1015
// If navigating to a hash anchor, scroll to it
1116
if (to.hash) {
1217
const { scrollMargin } = to.meta
1318
return {
1419
el: to.hash,
1520
behavior: 'smooth',
16-
top: typeof scrollMargin == 'number' ? scrollMargin : 70,
21+
top: typeof scrollMargin === 'number' ? scrollMargin : 70,
1722
}
1823
}
1924

i18n/locales/it-IT.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@
7676
"links": "Link",
7777
"tap_to_search": "Tocca per cercare"
7878
},
79+
"action_bar": {
80+
"view_selection": "Visualizza selezione"
81+
},
7982
"settings": {
8083
"title": "impostazioni",
8184
"tagline": "personalizza la tua esperienza npmx",

lunaria/files/it-IT.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@
7575
"links": "Link",
7676
"tap_to_search": "Tocca per cercare"
7777
},
78+
"action_bar": {
79+
"view_selection": "Visualizza selezione"
80+
},
7881
"settings": {
7982
"title": "impostazioni",
8083
"tagline": "personalizza la tua esperienza npmx",

shared/types/npm-registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ export interface NpmSearchResponse {
128128

129129
export interface NpmSearchResult {
130130
package: NpmSearchPackage
131-
score: NpmSearchScore
132-
searchScore: number
131+
score?: NpmSearchScore
132+
searchScore?: number
133133
/** Download counts (weekly/monthly) */
134134
downloads?: {
135135
weekly?: number

test/unit/shared/types/index.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,6 @@ describe('npm registry types', () => {
6464

6565
expect(response.total).toBe(1)
6666
expect(response.objects[0]?.package.name).toBe('test-package')
67-
expect(response.objects[0]?.score.final).toBe(0.9)
67+
expect(response.objects[0]?.score?.final).toBe(0.9)
6868
})
6969
})

0 commit comments

Comments
 (0)