Skip to content

Commit d8dd0db

Browse files
committed
feat: add bookmark buttons and logic
1 parent 8b70808 commit d8dd0db

File tree

12 files changed

+327
-2
lines changed

12 files changed

+327
-2
lines changed

app/components/AppHeader.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,12 @@ onKeyStroke(',', e => {
118118
</ul>
119119
</div>
120120

121-
<!-- End: User status + GitHub -->
121+
<!-- End: Bookmarks + Settings + Connector -->
122122
<div class="flex-shrink-0 flex items-center gap-4 sm:gap-6 ms-auto sm:ms-0">
123+
<ClientOnly>
124+
<HeaderBookmarksDropdown />
125+
</ClientOnly>
126+
123127
<NuxtLink
124128
to="/about"
125129
class="sm:hidden link-subtle font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"

app/components/BookmarkButton.vue

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
packageName: string
4+
}>()
5+
6+
const { useIsBookmarked, toggleBookmark } = useBookmarks()
7+
8+
const isBookmarked = useIsBookmarked(() => props.packageName)
9+
10+
function handleClick() {
11+
toggleBookmark(props.packageName)
12+
}
13+
</script>
14+
15+
<template>
16+
<button
17+
type="button"
18+
class="p-1.5 rounded transition-colors duration-150 border border-transparent hover:bg-bg hover:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
19+
:aria-label="
20+
isBookmarked
21+
? $t('bookmarks.remove', { name: packageName })
22+
: $t('bookmarks.add', { name: packageName })
23+
"
24+
:aria-pressed="isBookmarked"
25+
@click="handleClick"
26+
>
27+
<span
28+
class="block w-4 h-4 transition-colors duration-150"
29+
:class="
30+
isBookmarked
31+
? 'i-carbon-bookmark-filled text-accent'
32+
: 'i-carbon-bookmark text-fg-subtle hover:text-fg'
33+
"
34+
aria-hidden="true"
35+
/>
36+
</button>
37+
</template>
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<script setup lang="ts">
2+
const { bookmarks, hasBookmarks, removeBookmark, clearBookmarks } = useBookmarks()
3+
4+
const isOpen = ref(false)
5+
6+
function handleMouseEnter() {
7+
isOpen.value = true
8+
}
9+
10+
function handleMouseLeave() {
11+
isOpen.value = false
12+
}
13+
14+
function handleKeydown(event: KeyboardEvent) {
15+
if (event.key === 'Escape' && isOpen.value) {
16+
isOpen.value = false
17+
}
18+
}
19+
20+
function handleRemove(packageName: string, event: Event) {
21+
event.preventDefault()
22+
event.stopPropagation()
23+
removeBookmark(packageName)
24+
}
25+
26+
function handleClearAll(event: Event) {
27+
event.preventDefault()
28+
clearBookmarks()
29+
}
30+
</script>
31+
32+
<template>
33+
<div
34+
class="relative flex items-center"
35+
@mouseenter="handleMouseEnter"
36+
@mouseleave="handleMouseLeave"
37+
@keydown="handleKeydown"
38+
>
39+
<button
40+
type="button"
41+
class="link-subtle font-mono text-sm inline-flex items-center gap-1 leading-tight"
42+
:aria-expanded="isOpen"
43+
aria-haspopup="true"
44+
>
45+
<span
46+
class="w-[1em] h-[1em] shrink-0"
47+
:class="hasBookmarks ? 'i-carbon-bookmark-filled' : 'i-carbon-bookmark'"
48+
aria-hidden="true"
49+
/>
50+
<span class="hidden sm:inline">{{ $t('header.bookmarks') }}</span>
51+
<span
52+
class="hidden sm:inline i-carbon-chevron-down w-3 h-3 transition-transform duration-200"
53+
:class="{ 'rotate-180': isOpen }"
54+
aria-hidden="true"
55+
/>
56+
</button>
57+
58+
<Transition
59+
enter-active-class="transition-all duration-150"
60+
leave-active-class="transition-all duration-100"
61+
enter-from-class="opacity-0 translate-y-1"
62+
leave-to-class="opacity-0 translate-y-1"
63+
>
64+
<div v-if="isOpen" class="absolute inset-ie-0 top-full pt-2 w-72 z-50">
65+
<div class="bg-bg-elevated border border-border rounded-lg shadow-lg overflow-hidden">
66+
<div class="px-3 py-2 border-b border-border flex items-center justify-between">
67+
<span class="font-mono text-xs text-fg-subtle">
68+
{{ $t('header.bookmarks_dropdown.title') }}
69+
</span>
70+
<button
71+
v-if="hasBookmarks"
72+
type="button"
73+
class="font-mono text-xs text-fg-subtle hover:text-fg transition-colors"
74+
@click="handleClearAll"
75+
>
76+
{{ $t('header.bookmarks_dropdown.clear_all') }}
77+
</button>
78+
</div>
79+
80+
<ul v-if="hasBookmarks" class="py-1 max-h-80 overflow-y-auto">
81+
<li v-for="bookmark in bookmarks" :key="bookmark.packageName">
82+
<div class="flex items-center gap-1 px-3 hover:bg-bg-subtle transition-colors">
83+
<NuxtLink
84+
:to="`/${bookmark.packageName}`"
85+
class="flex-1 py-2 font-mono text-sm text-fg truncate"
86+
>
87+
{{ bookmark.packageName }}
88+
</NuxtLink>
89+
<button
90+
type="button"
91+
class="p-1 text-fg-subtle hover:text-fg transition-colors shrink-0"
92+
:aria-label="
93+
$t('header.bookmarks_dropdown.remove', { name: bookmark.packageName })
94+
"
95+
@click="handleRemove(bookmark.packageName, $event)"
96+
>
97+
<span class="i-carbon-close w-3 h-3 block" aria-hidden="true" />
98+
</button>
99+
</div>
100+
</li>
101+
</ul>
102+
103+
<div v-else class="px-3 py-4 text-center">
104+
<span class="text-fg-muted text-sm">
105+
{{ $t('header.bookmarks_dropdown.empty') }}
106+
</span>
107+
</div>
108+
</div>
109+
</div>
110+
</Transition>
111+
</div>
112+
</template>

app/composables/useBookmarks.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { RemovableRef } from '@vueuse/core'
2+
import { useLocalStorage } from '@vueuse/core'
3+
4+
/**
5+
* Bookmark entry with package name and timestamp
6+
*/
7+
export interface Bookmark {
8+
packageName: string
9+
addedAt: number
10+
}
11+
12+
const STORAGE_KEY = 'npmx-bookmarks'
13+
14+
// Shared bookmarks instance (singleton per app)
15+
let bookmarksRef: RemovableRef<Bookmark[]> | null = null
16+
17+
/**
18+
* Composable for managing package bookmarks with localStorage persistence.
19+
* Bookmarks are shared across all components that use this composable.
20+
*/
21+
export function useBookmarks() {
22+
if (!bookmarksRef) {
23+
bookmarksRef = useLocalStorage<Bookmark[]>(STORAGE_KEY, [], {
24+
mergeDefaults: true,
25+
})
26+
}
27+
28+
const bookmarks = bookmarksRef
29+
30+
/**
31+
* Check if a package is bookmarked
32+
*/
33+
function isBookmarked(packageName: string): boolean {
34+
return bookmarks.value.some(b => b.packageName === packageName)
35+
}
36+
37+
/**
38+
* Reactive computed to check if a specific package is bookmarked
39+
*/
40+
function useIsBookmarked(packageName: MaybeRefOrGetter<string>) {
41+
return computed(() => isBookmarked(toValue(packageName)))
42+
}
43+
44+
/**
45+
* Add a package to bookmarks
46+
*/
47+
function addBookmark(packageName: string): void {
48+
if (!isBookmarked(packageName)) {
49+
bookmarks.value = [{ packageName, addedAt: Date.now() }, ...bookmarks.value]
50+
}
51+
}
52+
53+
/**
54+
* Remove a package from bookmarks
55+
*/
56+
function removeBookmark(packageName: string): void {
57+
bookmarks.value = bookmarks.value.filter(b => b.packageName !== packageName)
58+
}
59+
60+
/**
61+
* Toggle bookmark status for a package
62+
*/
63+
function toggleBookmark(packageName: string): void {
64+
if (isBookmarked(packageName)) {
65+
removeBookmark(packageName)
66+
} else {
67+
addBookmark(packageName)
68+
}
69+
}
70+
71+
/**
72+
* Clear all bookmarks
73+
*/
74+
function clearBookmarks(): void {
75+
bookmarks.value = []
76+
}
77+
78+
const bookmarkCount = computed(() => bookmarks.value.length)
79+
const hasBookmarks = computed(() => bookmarks.value.length > 0)
80+
81+
return {
82+
bookmarks,
83+
bookmarkCount,
84+
hasBookmarks,
85+
isBookmarked,
86+
useIsBookmarked,
87+
addBookmark,
88+
removeBookmark,
89+
toggleBookmark,
90+
clearBookmarks,
91+
}
92+
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,12 +558,15 @@ defineOgImageComponent('Package', {
558558
</template>
559559
</ClientOnly>
560560

561-
<!-- Internal navigation: Docs + Code (hidden on mobile, shown in external links instead) -->
561+
<!-- Internal navigation: Bookmark + Docs + Code (hidden on mobile, shown in external links instead) -->
562562
<nav
563563
v-if="displayVersion"
564564
:aria-label="$t('package.navigation')"
565565
class="hidden sm:flex items-center gap-1 p-0.5 bg-bg-subtle border border-border-subtle rounded-md shrink-0 ml-auto self-center"
566566
>
567+
<ClientOnly>
568+
<BookmarkButton :package-name="pkg.name" />
569+
</ClientOnly>
567570
<NuxtLink
568571
v-if="docsLink"
569572
:to="docsLink"

i18n/locales/de-DE.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,9 +594,20 @@
594594
}
595595
}
596596
},
597+
"bookmarks": {
598+
"add": "{name} als Lesezeichen speichern",
599+
"remove": "{name} aus Lesezeichen entfernen"
600+
},
597601
"header": {
598602
"home": "npmx Startseite",
599603
"github": "GitHub",
604+
"bookmarks": "Lesezeichen",
605+
"bookmarks_dropdown": {
606+
"title": "Gespeicherte Pakete",
607+
"empty": "Noch keine Lesezeichen",
608+
"clear_all": "alle löschen",
609+
"remove": "{name} aus Lesezeichen entfernen"
610+
},
600611
"packages": "Pakete",
601612
"packages_dropdown": {
602613
"title": "Deine Pakete",

i18n/locales/en.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,9 +705,20 @@
705705
}
706706
}
707707
},
708+
"bookmarks": {
709+
"add": "Bookmark {name}",
710+
"remove": "Remove {name} from bookmarks"
711+
},
708712
"header": {
709713
"home": "npmx home",
710714
"github": "GitHub",
715+
"bookmarks": "bookmarks",
716+
"bookmarks_dropdown": {
717+
"title": "Bookmarked Packages",
718+
"empty": "No bookmarks yet",
719+
"clear_all": "clear all",
720+
"remove": "Remove {name} from bookmarks"
721+
},
711722
"packages": "packages",
712723
"packages_dropdown": {
713724
"title": "Your Packages",

i18n/locales/es.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,9 +556,20 @@
556556
}
557557
}
558558
},
559+
"bookmarks": {
560+
"add": "Guardar {name} en marcadores",
561+
"remove": "Eliminar {name} de marcadores"
562+
},
559563
"header": {
560564
"home": "inicio de npmx",
561565
"github": "GitHub",
566+
"bookmarks": "marcadores",
567+
"bookmarks_dropdown": {
568+
"title": "Paquetes guardados",
569+
"empty": "Sin marcadores",
570+
"clear_all": "eliminar todo",
571+
"remove": "Eliminar {name} de marcadores"
572+
},
562573
"packages": "paquetes",
563574
"packages_dropdown": {
564575
"title": "Tus Paquetes",

i18n/locales/fr-FR.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,9 +629,20 @@
629629
}
630630
}
631631
},
632+
"bookmarks": {
633+
"add": "Ajouter {name} aux favoris",
634+
"remove": "Retirer {name} des favoris"
635+
},
632636
"header": {
633637
"home": "accueil npmx",
634638
"github": "GitHub",
639+
"bookmarks": "favoris",
640+
"bookmarks_dropdown": {
641+
"title": "Paquets favoris",
642+
"empty": "Aucun favori",
643+
"clear_all": "tout supprimer",
644+
"remove": "Retirer {name} des favoris"
645+
},
635646
"packages": "paquets",
636647
"packages_dropdown": {
637648
"title": "Vos paquets",

i18n/locales/it-IT.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,9 +705,20 @@
705705
}
706706
}
707707
},
708+
"bookmarks": {
709+
"add": "Aggiungi {name} ai preferiti",
710+
"remove": "Rimuovi {name} dai preferiti"
711+
},
708712
"header": {
709713
"home": "npmx home",
710714
"github": "GitHub",
715+
"bookmarks": "preferiti",
716+
"bookmarks_dropdown": {
717+
"title": "Pacchetti preferiti",
718+
"empty": "Nessun preferito",
719+
"clear_all": "elimina tutti",
720+
"remove": "Rimuovi {name} dai preferiti"
721+
},
711722
"packages": "pacchetti",
712723
"packages_dropdown": {
713724
"title": "I tuoi pacchetti",

0 commit comments

Comments
 (0)