Skip to content

Commit 69facd6

Browse files
committed
feat: show recently viewed packages/orgs/users on homepage
This replaces the hardcoded list of quick links to frameworks. Instead, we now track up to 5 most recently viewed entities (packages, orgs, users) in localStorage and show them on the homepage. This allows users to quickly navigate back to previously viewed pages. If no recently viewed items are known, the section is hidden. There's potential for this to eventually be powered by atproto and tied to a user (and therefore to sync across devices), but we can tackle that later (among other things, there are data privacy implications).
1 parent 94ea122 commit 69facd6

12 files changed

Lines changed: 223 additions & 27 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { RemovableRef } from '@vueuse/core'
2+
import { useLocalStorage } from '@vueuse/core'
3+
import { computed } from 'vue'
4+
5+
const MAX_RECENT_ITEMS = 5
6+
const STORAGE_KEY = 'npmx-recent'
7+
8+
export type RecentItemType = 'package' | 'org' | 'user'
9+
10+
export interface RecentItem {
11+
type: RecentItemType
12+
/** Canonical identifier: package name, org name (without @), or username */
13+
name: string
14+
/** Display label shown on homepage (e.g. "@nuxt", "~sindresorhus") */
15+
label: string
16+
/** Unix timestamp (ms) of most recent view */
17+
viewedAt: number
18+
}
19+
20+
let recentRef: RemovableRef<RecentItem[]> | null = null
21+
22+
function getRecentRef() {
23+
if (!recentRef) {
24+
recentRef = useLocalStorage<RecentItem[]>(STORAGE_KEY, [])
25+
}
26+
return recentRef
27+
}
28+
29+
export function useRecentlyViewed() {
30+
const items = getRecentRef()
31+
return { items: computed(() => items.value) }
32+
}
33+
34+
export function trackRecentView(item: Omit<RecentItem, 'viewedAt'>) {
35+
if (import.meta.server) return
36+
const items = getRecentRef()
37+
const filtered = items.value.filter(
38+
existing => !(existing.type === item.type && existing.name === item.name),
39+
)
40+
filtered.unshift({ ...item, viewedAt: Date.now() })
41+
items.value = filtered.slice(0, MAX_RECENT_ITEMS)
42+
}

app/pages/index.vue

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
2-
import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks'
2+
import type { RecentItem } from '~/composables/useRecentlyViewed'
3+
import type { RouteLocationRaw } from 'vue-router'
34
45
const { model: searchQuery, flushUpdateUrlQuery } = useGlobalSearch()
56
const isSearchFocused = shallowRef(false)
@@ -24,6 +25,19 @@ defineOgImageComponent('Default', {
2425
title: 'npmx',
2526
description: 'a fast, modern browser for the **npm registry**',
2627
})
28+
29+
const { items: recentItems } = useRecentlyViewed()
30+
31+
function recentItemRoute(item: RecentItem): RouteLocationRaw {
32+
switch (item.type) {
33+
case 'package':
34+
return packageRoute(item.name)
35+
case 'org':
36+
return { name: 'org', params: { org: item.name } }
37+
case 'user':
38+
return { name: '~username', params: { username: item.name } }
39+
}
40+
}
2741
</script>
2842

2943
<template>
@@ -107,22 +121,31 @@ defineOgImageComponent('Default', {
107121
<BuildEnvironment class="mt-4" />
108122
</header>
109123

110-
<nav
111-
:aria-label="$t('nav.popular_packages')"
112-
class="pt-4 pb-36 sm:pb-40 text-center motion-safe:animate-fade-in motion-safe:animate-fill-both max-w-xl mx-auto"
113-
style="animation-delay: 0.3s"
114-
>
115-
<ul class="flex flex-wrap items-center justify-center gap-x-6 gap-y-3 list-none m-0 p-0">
116-
<li v-for="framework in SHOWCASED_FRAMEWORKS" :key="framework.name">
117-
<LinkBase :to="packageRoute(framework.package)" class="gap-2 text-sm">
118-
<span
119-
class="home-tag-dot w-1 h-1 rounded-full bg-accent group-hover:bg-fg transition-colors duration-200"
120-
/>
121-
{{ framework.name }}
122-
</LinkBase>
123-
</li>
124-
</ul>
125-
</nav>
124+
<div class="pt-4 pb-36 sm:pb-40 max-w-xl mx-auto">
125+
<ClientOnly>
126+
<nav
127+
v-if="recentItems.length > 0"
128+
:aria-label="$t('nav.recently_viewed')"
129+
class="text-center motion-safe:animate-fade-in motion-safe:animate-fill-both"
130+
style="animation-delay: 0.3s"
131+
>
132+
<div class="flex flex-wrap items-center justify-center gap-x-2 gap-y-3">
133+
<span class="text-xs text-fg-subtle tracking-wider">
134+
{{ $t('nav.recently_viewed') }}:
135+
</span>
136+
<ul
137+
class="flex flex-wrap items-center justify-center gap-x-4 gap-y-3 list-none m-0 p-0"
138+
>
139+
<li v-for="item in recentItems" :key="`${item.type}-${item.name}`">
140+
<LinkBase :to="recentItemRoute(item)" class="text-sm">
141+
{{ item.label }}
142+
</LinkBase>
143+
</li>
144+
</ul>
145+
</div>
146+
</nav>
147+
</ClientOnly>
148+
</div>
126149
</section>
127150

128151
<section class="border-t border-border py-24 bg-bg-subtle/10">
@@ -132,13 +155,3 @@ defineOgImageComponent('Default', {
132155
</section>
133156
</main>
134157
</template>
135-
136-
<style scoped>
137-
/* Windows High Contrast Mode support */
138-
@media (forced-colors: active) {
139-
.home-tag-dot {
140-
forced-color-adjust: none;
141-
background-color: CanvasText;
142-
}
143-
}
144-
</style>

app/pages/org/[org].vue

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,18 @@ watch(orgName, () => {
121121
currentPage.value = 1
122122
})
123123
124+
if (import.meta.client) {
125+
watch(
126+
() => [status.value, orgName.value] as const,
127+
([s, name]) => {
128+
if (s === 'success') {
129+
trackRecentView({ type: 'org', name, label: `@${name}` })
130+
}
131+
},
132+
{ immediate: true },
133+
)
134+
}
135+
124136
// Handle filter chip removal
125137
function handleClearFilter(chip: FilterChip) {
126138
clearFilter(chip)

app/pages/package/[[org]]/[name].vue

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,18 @@ onKeyStroke(
643643
)
644644
645645
const showSkeleton = shallowRef(false)
646+
647+
if (import.meta.client) {
648+
watch(
649+
() => [status.value, packageName.value] as const,
650+
([s, name]) => {
651+
if (s === 'success') {
652+
trackRecentView({ type: 'package', name, label: name })
653+
}
654+
},
655+
{ immediate: true },
656+
)
657+
}
646658
</script>
647659

648660
<template>

app/pages/~[username]/index.vue

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ watch(username, () => {
119119
sortOption.value = 'downloads'
120120
})
121121
122+
if (import.meta.client) {
123+
watch(
124+
() => [status.value, username.value] as const,
125+
([s, name]) => {
126+
if (s === 'success') {
127+
trackRecentView({ type: 'user', name, label: `~${name}` })
128+
}
129+
},
130+
{ immediate: true },
131+
)
132+
}
133+
122134
useSeoMeta({
123135
title: () => `~${username.value} - npmx`,
124136
ogTitle: () => `~${username.value} - npmx`,

i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"nav": {
6868
"main_navigation": "Main",
6969
"popular_packages": "Popular packages",
70+
"recently_viewed": "recently viewed",
7071
"settings": "settings",
7172
"compare": "compare",
7273
"back": "back",

i18n/locales/fr-FR.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"nav": {
6868
"main_navigation": "Barre de navigation",
6969
"popular_packages": "Paquets populaires",
70+
"recently_viewed": "consultés récemment",
7071
"settings": "paramètres",
7172
"compare": "comparer",
7273
"back": "Retour",

i18n/schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,9 @@
205205
"popular_packages": {
206206
"type": "string"
207207
},
208+
"recently_viewed": {
209+
"type": "string"
210+
},
208211
"settings": {
209212
"type": "string"
210213
},

lunaria/files/en-GB.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"nav": {
6767
"main_navigation": "Main",
6868
"popular_packages": "Popular packages",
69+
"recently_viewed": "recently viewed",
6970
"settings": "settings",
7071
"compare": "compare",
7172
"back": "back",

lunaria/files/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"nav": {
6767
"main_navigation": "Main",
6868
"popular_packages": "Popular packages",
69+
"recently_viewed": "recently viewed",
6970
"settings": "settings",
7071
"compare": "compare",
7172
"back": "back",

0 commit comments

Comments
 (0)