Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,12 @@ onKeyStroke(',', e => {
</ul>
</div>

<!-- End: User status + GitHub -->
<!-- End: Bookmarks + Settings + Connector -->
<div class="flex-shrink-0 flex items-center gap-4 sm:gap-6 ms-auto sm:ms-0">
<ClientOnly>
<HeaderBookmarksDropdown />
</ClientOnly>

<NuxtLink
to="/about"
class="sm:hidden link-subtle font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"
Expand Down
37 changes: 37 additions & 0 deletions app/components/BookmarkButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script setup lang="ts">
const props = defineProps<{
packageName: string
}>()

const { useIsBookmarked, toggleBookmark } = useBookmarks()

const isBookmarked = useIsBookmarked(() => props.packageName)

function handleClick() {
toggleBookmark(props.packageName)
}
</script>

<template>
<button
type="button"
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"
:aria-label="
isBookmarked
? $t('bookmarks.remove', { name: packageName })
: $t('bookmarks.add', { name: packageName })
"
:aria-pressed="isBookmarked"
@click="handleClick"
>
<span
class="block w-4 h-4 transition-colors duration-150"
:class="
isBookmarked
? 'i-carbon-bookmark-filled text-accent'
: 'i-carbon-bookmark text-fg-subtle hover:text-fg'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use : => i-carbon:

"
aria-hidden="true"
/>
</button>
</template>
112 changes: 112 additions & 0 deletions app/components/HeaderBookmarksDropdown.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<script setup lang="ts">
const { bookmarks, hasBookmarks, removeBookmark, clearBookmarks } = useBookmarks()

const isOpen = ref(false)

function handleMouseEnter() {
isOpen.value = true
}

function handleMouseLeave() {
isOpen.value = false
}

function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && isOpen.value) {
isOpen.value = false
}
}

function handleRemove(packageName: string, event: Event) {
event.preventDefault()
event.stopPropagation()
removeBookmark(packageName)
}

function handleClearAll(event: Event) {
event.preventDefault()
clearBookmarks()
}
</script>

<template>
<div
class="relative flex items-center"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@keydown="handleKeydown"
>
<button
type="button"
class="link-subtle font-mono text-sm inline-flex items-center gap-1 leading-tight"
:aria-expanded="isOpen"
aria-haspopup="true"
>
<span
class="w-[1em] h-[1em] shrink-0"
:class="hasBookmarks ? 'i-carbon-bookmark-filled' : 'i-carbon-bookmark'"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

aria-hidden="true"
/>
<span class="hidden sm:inline">{{ $t('header.bookmarks') }}</span>
<span
class="hidden sm:inline i-carbon-chevron-down w-3 h-3 transition-transform duration-200"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and here

:class="{ 'rotate-180': isOpen }"
aria-hidden="true"
/>
</button>

<Transition
enter-active-class="transition-all duration-150"
leave-active-class="transition-all duration-100"
enter-from-class="opacity-0 translate-y-1"
leave-to-class="opacity-0 translate-y-1"
>
<div v-if="isOpen" class="absolute inset-ie-0 top-full pt-2 w-72 z-50">
<div class="bg-bg-elevated border border-border rounded-lg shadow-lg overflow-hidden">
<div class="px-3 py-2 border-b border-border flex items-center justify-between">
<span class="font-mono text-xs text-fg-subtle">
{{ $t('header.bookmarks_dropdown.title') }}
</span>
<button
v-if="hasBookmarks"
type="button"
class="font-mono text-xs text-fg-subtle hover:text-fg transition-colors"
@click="handleClearAll"
>
{{ $t('header.bookmarks_dropdown.clear_all') }}
</button>
</div>

<ul v-if="hasBookmarks" class="py-1 max-h-80 overflow-y-auto">
<li v-for="bookmark in bookmarks" :key="bookmark.packageName">
<div class="flex items-center gap-1 px-3 hover:bg-bg-subtle transition-colors">
<NuxtLink
:to="`/${bookmark.packageName}`"
class="flex-1 py-2 font-mono text-sm text-fg truncate"
>
{{ bookmark.packageName }}
</NuxtLink>
<button
type="button"
class="p-1 text-fg-subtle hover:text-fg transition-colors shrink-0"
:aria-label="
$t('header.bookmarks_dropdown.remove', { name: bookmark.packageName })
"
@click="handleRemove(bookmark.packageName, $event)"
>
<span class="i-carbon-close w-3 h-3 block" aria-hidden="true" />
</button>
</div>
</li>
</ul>

<div v-else class="px-3 py-4 text-center">
<span class="text-fg-muted text-sm">
{{ $t('header.bookmarks_dropdown.empty') }}
</span>
</div>
</div>
</div>
</Transition>
</div>
</template>
92 changes: 92 additions & 0 deletions app/composables/useBookmarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { RemovableRef } from '@vueuse/core'
import { useLocalStorage } from '@vueuse/core'

/**
* Bookmark entry with package name and timestamp
*/
export interface Bookmark {
packageName: string
addedAt: number
}

const STORAGE_KEY = 'npmx-bookmarks'

// Shared bookmarks instance (singleton per app)
let bookmarksRef: RemovableRef<Bookmark[]> | null = null

/**
* Composable for managing package bookmarks with localStorage persistence.
* Bookmarks are shared across all components that use this composable.
*/
export function useBookmarks() {
if (!bookmarksRef) {
bookmarksRef = useLocalStorage<Bookmark[]>(STORAGE_KEY, [], {
mergeDefaults: true,
})
}

const bookmarks = bookmarksRef

/**
* Check if a package is bookmarked
*/
function isBookmarked(packageName: string): boolean {
return bookmarks.value.some(b => b.packageName === packageName)
}

/**
* Reactive computed to check if a specific package is bookmarked
*/
function useIsBookmarked(packageName: MaybeRefOrGetter<string>) {
return computed(() => isBookmarked(toValue(packageName)))
}

/**
* Add a package to bookmarks
*/
function addBookmark(packageName: string): void {
if (!isBookmarked(packageName)) {
bookmarks.value = [{ packageName, addedAt: Date.now() }, ...bookmarks.value]
}
}

/**
* Remove a package from bookmarks
*/
function removeBookmark(packageName: string): void {
bookmarks.value = bookmarks.value.filter(b => b.packageName !== packageName)
}

/**
* Toggle bookmark status for a package
*/
function toggleBookmark(packageName: string): void {
if (isBookmarked(packageName)) {
removeBookmark(packageName)
} else {
addBookmark(packageName)
}
}

/**
* Clear all bookmarks
*/
function clearBookmarks(): void {
bookmarks.value = []
}

const bookmarkCount = computed(() => bookmarks.value.length)
const hasBookmarks = computed(() => bookmarks.value.length > 0)

return {
bookmarks,
bookmarkCount,
hasBookmarks,
isBookmarked,
useIsBookmarked,
addBookmark,
removeBookmark,
toggleBookmark,
clearBookmarks,
}
}
5 changes: 4 additions & 1 deletion app/pages/[...package].vue
Original file line number Diff line number Diff line change
Expand Up @@ -576,12 +576,15 @@ function handleClick(event: MouseEvent) {
</template>
</ClientOnly>

<!-- Internal navigation: Docs + Code (hidden on mobile, shown in external links instead) -->
<!-- Internal navigation: Bookmark + Docs + Code (hidden on mobile, shown in external links instead) -->
<nav
v-if="displayVersion"
:aria-label="$t('package.navigation')"
class="hidden sm:flex items-center gap-1 p-0.5 bg-bg-subtle border border-border-subtle rounded-md shrink-0 ms-auto self-center"
>
<ClientOnly>
<BookmarkButton :package-name="pkg.name" />
</ClientOnly>
<NuxtLink
v-if="docsLink"
:to="docsLink"
Expand Down
11 changes: 11 additions & 0 deletions i18n/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -599,9 +599,20 @@
}
}
},
"bookmarks": {
"add": "{name} als Lesezeichen speichern",
"remove": "{name} aus Lesezeichen entfernen"
},
"header": {
"home": "npmx Startseite",
"github": "GitHub",
"bookmarks": "Lesezeichen",
"bookmarks_dropdown": {
"title": "Gespeicherte Pakete",
"empty": "Noch keine Lesezeichen",
"clear_all": "alle löschen",
"remove": "{name} aus Lesezeichen entfernen"
},
"packages": "Pakete",
"packages_dropdown": {
"title": "Deine Pakete",
Expand Down
11 changes: 11 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -710,9 +710,20 @@
}
}
},
"bookmarks": {
"add": "Bookmark {name}",
"remove": "Remove {name} from bookmarks"
},
"header": {
"home": "npmx home",
"github": "GitHub",
"bookmarks": "bookmarks",
"bookmarks_dropdown": {
"title": "Bookmarked Packages",
"empty": "No bookmarks yet",
"clear_all": "clear all",
"remove": "Remove {name} from bookmarks"
},
"packages": "packages",
"packages_dropdown": {
"title": "Your Packages",
Expand Down
11 changes: 11 additions & 0 deletions i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -561,9 +561,20 @@
}
}
},
"bookmarks": {
"add": "Guardar {name} en marcadores",
"remove": "Eliminar {name} de marcadores"
},
"header": {
"home": "inicio de npmx",
"github": "GitHub",
"bookmarks": "marcadores",
"bookmarks_dropdown": {
"title": "Paquetes guardados",
"empty": "Sin marcadores",
"clear_all": "eliminar todo",
"remove": "Eliminar {name} de marcadores"
},
"packages": "paquetes",
"packages_dropdown": {
"title": "Tus Paquetes",
Expand Down
11 changes: 11 additions & 0 deletions i18n/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -710,9 +710,20 @@
}
}
},
"bookmarks": {
"add": "Ajouter {name} aux favoris",
"remove": "Retirer {name} des favoris"
},
"header": {
"home": "accueil npmx",
"github": "GitHub",
"bookmarks": "favoris",
"bookmarks_dropdown": {
"title": "Paquets favoris",
"empty": "Aucun favori",
"clear_all": "tout supprimer",
"remove": "Retirer {name} des favoris"
},
"packages": "paquets",
"packages_dropdown": {
"title": "Vos paquets",
Expand Down
11 changes: 11 additions & 0 deletions i18n/locales/it-IT.json
Original file line number Diff line number Diff line change
Expand Up @@ -710,9 +710,20 @@
}
}
},
"bookmarks": {
"add": "Aggiungi {name} ai preferiti",
"remove": "Rimuovi {name} dai preferiti"
},
"header": {
"home": "npmx home",
"github": "GitHub",
"bookmarks": "preferiti",
"bookmarks_dropdown": {
"title": "Pacchetti preferiti",
"empty": "Nessun preferito",
"clear_all": "elimina tutti",
"remove": "Rimuovi {name} dai preferiti"
},
"packages": "pacchetti",
"packages_dropdown": {
"title": "I tuoi pacchetti",
Expand Down
Loading
Loading