From 977ef3c3a904f43e36439358d44b7c48caefb27f Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 22 Feb 2026 22:10:06 -0500 Subject: [PATCH 1/4] 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). --- app/composables/useRecentlyViewed.ts | 42 ++++++++ app/pages/index.vue | 67 +++++++------ app/pages/org/[org].vue | 12 +++ app/pages/package/[[org]]/[name].vue | 12 +++ app/pages/~[username]/index.vue | 12 +++ i18n/locales/en.json | 1 + i18n/locales/fr-FR.json | 1 + i18n/schema.json | 3 + lunaria/files/en-GB.json | 1 + lunaria/files/en-US.json | 1 + lunaria/files/fr-FR.json | 1 + .../composables/use-recently-viewed.spec.ts | 97 +++++++++++++++++++ 12 files changed, 223 insertions(+), 27 deletions(-) create mode 100644 app/composables/useRecentlyViewed.ts create mode 100644 test/unit/app/composables/use-recently-viewed.spec.ts diff --git a/app/composables/useRecentlyViewed.ts b/app/composables/useRecentlyViewed.ts new file mode 100644 index 000000000..1120229d3 --- /dev/null +++ b/app/composables/useRecentlyViewed.ts @@ -0,0 +1,42 @@ +import type { RemovableRef } from '@vueuse/core' +import { useLocalStorage } from '@vueuse/core' +import { computed } from 'vue' + +const MAX_RECENT_ITEMS = 5 +const STORAGE_KEY = 'npmx-recent' + +export type RecentItemType = 'package' | 'org' | 'user' + +export interface RecentItem { + type: RecentItemType + /** Canonical identifier: package name, org name (without @), or username */ + name: string + /** Display label shown on homepage (e.g. "@nuxt", "~sindresorhus") */ + label: string + /** Unix timestamp (ms) of most recent view */ + viewedAt: number +} + +let recentRef: RemovableRef | null = null + +function getRecentRef() { + if (!recentRef) { + recentRef = useLocalStorage(STORAGE_KEY, []) + } + return recentRef +} + +export function useRecentlyViewed() { + const items = getRecentRef() + return { items: computed(() => items.value) } +} + +export function trackRecentView(item: Omit) { + if (import.meta.server) return + const items = getRecentRef() + const filtered = items.value.filter( + existing => !(existing.type === item.type && existing.name === item.name), + ) + filtered.unshift({ ...item, viewedAt: Date.now() }) + items.value = filtered.slice(0, MAX_RECENT_ITEMS) +} diff --git a/app/pages/index.vue b/app/pages/index.vue index 74d38d65d..0079d7442 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,5 +1,6 @@ - - diff --git a/app/pages/org/[org].vue b/app/pages/org/[org].vue index 3291640cd..f18158602 100644 --- a/app/pages/org/[org].vue +++ b/app/pages/org/[org].vue @@ -121,6 +121,18 @@ watch(orgName, () => { currentPage.value = 1 }) +if (import.meta.client) { + watch( + () => [status.value, orgName.value] as const, + ([s, name]) => { + if (s === 'success') { + trackRecentView({ type: 'org', name, label: `@${name}` }) + } + }, + { immediate: true }, + ) +} + // Handle filter chip removal function handleClearFilter(chip: FilterChip) { clearFilter(chip) diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index de6c114d9..bb8299b81 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -643,6 +643,18 @@ onKeyStroke( ) const showSkeleton = shallowRef(false) + +if (import.meta.client) { + watch( + () => [status.value, packageName.value] as const, + ([s, name]) => { + if (s === 'success') { + trackRecentView({ type: 'package', name, label: name }) + } + }, + { immediate: true }, + ) +}