Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ const desktopLinks = computed<NavigationConfig>(() => [
external: false,
iconClass: 'i-lucide:git-compare',
},
{
name: 'Collections',
label: $t('nav.collections'),
to: { name: 'collections' },
type: 'link',
external: false,
iconClass: 'i-lucide:library',
},
{
name: 'Settings',
label: $t('nav.settings'),
Expand Down
21 changes: 21 additions & 0 deletions app/components/Collection/Card.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,27 @@ type Story = StoryObj<typeof meta>

export const Default: Story = {}

/** Collections from allowlisted accounts, such as npmx's own, get a badge. */
export const Curated: Story = {
args: {
collection: {
...mockCollectionSummary,
did: 'did:plc:u5zp7npt5kpueado77kuihyz',
handle: 'npmx.dev',
},
},
}

export const WithoutDescription: Story = {
args: { collection: { ...mockCollectionSummary, description: undefined } },
}

/** Browse adds counts and when the collection was last edited. */
export const WithStats: Story = {
args: {
collection: {
...mockCollectionSummary,
stats: { likes: 12, follows: 5, updatedAt: '2026-11-03T00:00:00.000Z' },
},
},
}
67 changes: 53 additions & 14 deletions app/components/Collection/Card.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
<script setup lang="ts">
import type { CollectionSummary } from '#shared/types/social'
import type { BrowseCollection, CollectionSummary } from '#shared/types/social'
import { CURATED_COLLECTION_DIDS } from '#shared/utils/constants'

defineProps<{
collection: CollectionSummary
const props = defineProps<{
/** Browse's counts show when present */
collection: CollectionSummary | BrowseCollection
/** 3 by default, for lists under an h2 */
headingLevel?: 2 | 3
}>()

const isCurated = computed(() => CURATED_COLLECTION_DIDS.has(props.collection.did))
const stats = computed(() => ('stats' in props.collection ? props.collection.stats : undefined))
</script>

<template>
Expand All @@ -17,23 +22,57 @@ defineProps<{
class="block h-full"
>
<BaseCard class="flex h-full flex-col gap-2 min-w-0">
<component
:is="`h${headingLevel ?? 3}`"
class="font-mono text-base font-medium truncate min-w-0"
:title="collection.name"
>
{{ collection.name }}
</component>
<div class="flex items-start justify-between gap-3 min-w-0">
<component
:is="`h${headingLevel ?? 3}`"
class="font-mono text-base font-medium truncate min-w-0"
:title="collection.name"
>
{{ collection.name }}
</component>
<CollectionCuratedBadge v-if="isCurated" class="shrink-0" />
</div>
<p class="font-mono text-sm text-fg-muted truncate">
{{ $t('collections.by', { handle: collection.handle }) }}
</p>
<p v-if="collection.description" class="text-sm text-fg-muted line-clamp-2">
{{ collection.description }}
</p>
<p class="mt-auto font-mono text-xs text-fg-subtle">
{{
$t('collections.item_count', { count: $n(collection.itemCount) }, collection.itemCount)
}}
<p
class="mt-auto flex flex-wrap items-center gap-x-4 gap-y-1 font-mono text-xs text-fg-subtle"
>
<span>
{{
$t('collections.item_count', { count: $n(collection.itemCount) }, collection.itemCount)
}}
</span>
<template v-if="stats">
<span
class="flex items-center gap-1"
:title="$t('collections.like_count', { count: $n(stats.likes) }, stats.likes)"
>
<span class="i-lucide:heart size-3.5" aria-hidden="true" />
<span aria-hidden="true">{{ $n(stats.likes) }}</span>
<span class="sr-only">
{{ $t('collections.like_count', { count: $n(stats.likes) }, stats.likes) }}
</span>
</span>
<span
class="flex items-center gap-1"
:title="$t('collections.followers', { count: $n(stats.follows) }, stats.follows)"
>
<span class="i-lucide:bell size-3.5" aria-hidden="true" />
<span aria-hidden="true">{{ $n(stats.follows) }}</span>
<span class="sr-only">
{{ $t('collections.followers', { count: $n(stats.follows) }, stats.follows) }}
</span>
</span>
<span class="ms-auto flex items-center gap-1" :title="$t('collections.updated')">
<span class="i-lucide:history size-3.5" aria-hidden="true" />
<span class="sr-only">{{ $t('collections.updated') }}</span>
<DateTime :datetime="stats.updatedAt" year="numeric" month="short" day="numeric" />
</span>
</template>
</p>
</BaseCard>
</NuxtLink>
Expand Down
3 changes: 3 additions & 0 deletions app/components/Collection/CuratedBadge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<TagStatic classicon="i-lucide:badge-check">{{ $t('collections.curated') }}</TagStatic>
</template>
4 changes: 2 additions & 2 deletions app/components/Collection/Grid.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { CollectionSummary } from '#shared/types/social'
import type { BrowseCollection, CollectionSummary } from '#shared/types/social'

const {
items,
loadingMore,
skeletons = 6,
} = defineProps<{
items: CollectionSummary[]
items: (CollectionSummary | BrowseCollection)[]
/** Shows skeletons while there are no items yet */
pending: boolean
loadingMore: boolean
Expand Down
10 changes: 10 additions & 0 deletions app/composables/useCommandPaletteGlobalCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,16 @@ export function useCommandPaletteGlobalCommands() {
activeLabel: activeLabel(route.name === 'tools-deps-stats', t('command_palette.here')),
to: { name: 'tools-deps-stats' },
},
{
id: 'collections',
group: 'navigation',
label: t('nav.collections'),
keywords: [t('collections.title'), t('collections.tagline')],
iconClass: 'i-lucide:library',
active: route.name === 'collections',
activeLabel: activeLabel(route.name === 'collections', t('command_palette.here')),
to: { name: 'collections' },
},
{
id: 'collections-new',
group: 'navigation',
Expand Down
28 changes: 25 additions & 3 deletions app/pages/collections/[identifier]/[rkey].vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<script setup lang="ts">
import type { CollectionView, ReactionState } from '#shared/types/social'
import type { CommandPaletteContextCommandInput } from '~/types/command-palette'
import {
COLLECTIONS_INDEX_INTERVAL_MINUTES,
CURATED_COLLECTION_DIDS,
} from '#shared/utils/constants'
import {
collectionPath,
deleteCollection,
Expand Down Expand Up @@ -37,7 +41,10 @@ const packageNames = computed(() =>
const { data: packageCards } = usePackagesByName(packageNames)

const { user } = useAtproto()
// Set by the new-collection page: browse is fed by a periodic index, so say why it isn't there yet
const justCreated = computed(() => route.query.created === '1')
const isOwner = computed(() => !!user.value?.did && user.value.did === collection.value?.did)
const isCurated = computed(() => CURATED_COLLECTION_DIDS.has(collection.value?.did ?? ''))

// useFetch data is a shallow ref, so replace rather than mutate
const reactionModel = (key: 'likes' | 'follows') =>
Expand Down Expand Up @@ -241,9 +248,12 @@ defineOgImage(
<template>
<main v-if="collection" class="container flex-1 flex flex-col gap-8 py-8 sm:py-12 w-full">
<header class="flex flex-col gap-4 border-b border-border pb-8">
<h1 class="font-mono text-2xl sm:text-3xl font-medium min-w-0 break-words">
{{ collection.name }}
</h1>
<div class="flex flex-wrap items-center gap-3">
<h1 class="font-mono text-2xl sm:text-3xl font-medium min-w-0 break-words">
{{ collection.name }}
</h1>
<CollectionCuratedBadge v-if="isCurated" />
</div>
<p class="font-mono text-sm">
<LinkBase :to="{ name: 'profile-identity', params: { identity: collection.handle } }">
{{ $t('collections.by', { handle: collection.handle }) }}
Expand Down Expand Up @@ -294,6 +304,18 @@ defineOgImage(
</div>
</header>

<p
v-if="justCreated && isOwner"
class="flex items-start gap-2 rounded-lg border border-border bg-bg-subtle p-4 text-sm"
>
<span class="i-lucide:clock mt-0.5 size-4 shrink-0" aria-hidden="true" />
{{
$t('collections.created_notice', {
minutes: $n(COLLECTIONS_INDEX_INTERVAL_MINUTES),
})
}}
</p>

<CollectionEditor
v-if="isEditing"
ref="editor"
Expand Down
75 changes: 59 additions & 16 deletions app/pages/collections/index.vue
Original file line number Diff line number Diff line change
@@ -1,26 +1,38 @@
<script setup lang="ts">
import type { CollectionSummary } from '#shared/types/social'
import type { BrowseCollection, CollectionSummary } from '#shared/types/social'
import { parseCollectionQuery } from '~/utils/atproto/collections'

type Page = { cursor?: string; total?: number; items: CollectionSummary[] }
type Page = { cursor?: string; total?: number; items: (CollectionSummary | BrowseCollection)[] }

const route = useRoute('collections')

// Collections containing a package, or including another collection
// Three views share this page: browse everything, collections containing a package,
// and collections including another collection.
const packageName = computed(() =>
typeof route.query.package === 'string' ? route.query.package : '',
)
const includes = computed(() => parseCollectionQuery(route.query.includes))
const SORTS = ['recent', 'liked', 'followed'] as const
const sort = computed(() => SORTS.find(s => s !== 'recent' && s === route.query.sort) ?? 'recent')
const sortLabels = computed(() => ({
recent: $t('collections.sort_recent'),
liked: $t('collections.sort_liked'),
followed: $t('collections.sort_followed'),
}))

// Our own links use DIDs, which the AT-URI needs
const containing = computed(() => {
if (packageName.value) return PACKAGE_SUBJECT_REF(packageName.value)
if (includes.value) return COLLECTION_SUBJECT_REF(includes.value.identifier, includes.value.rkey)
return null
})
if (!containing.value) throw createError({ statusCode: 404 })
const endpoint = '/api/social/collections/containing'
const baseQuery = computed(() => ({ subject: containing.value }))
const isBrowsing = computed(() => !containing.value)
const endpoint = computed(() =>
isBrowsing.value ? '/api/social/collections/browse' : '/api/social/collections/containing',
)
const baseQuery = computed(() =>
isBrowsing.value ? { sort: sort.value } : { subject: containing.value },
)

const { data, status, error } = await useFetch<Page>(endpoint, { query: baseQuery })

Expand All @@ -30,7 +42,7 @@ async function loadMore() {
if (!current?.cursor || isLoadingMore.value) return
isLoadingMore.value = true
try {
const page = await $fetch<Page>(endpoint, {
const page = await $fetch<Page>(endpoint.value, {
query: { ...baseQuery.value, cursor: current.cursor },
})
// The query changed while loading, so this page belongs to the old one
Expand All @@ -42,23 +54,40 @@ async function loadMore() {
}
}

const heading = computed(() =>
packageName.value
? $t('collections.containing_package_title', { name: packageName.value })
: $t('collections.including_title'),
)
const heading = computed(() => {
if (packageName.value)
return $t('collections.containing_package_title', { name: packageName.value })
if (includes.value) return $t('collections.including_title')
return $t('collections.title')
})

useSeoMeta({
title: heading,
ogTitle: heading,
twitterTitle: heading,
title: () => $t('collections.meta_title'),
ogTitle: () => $t('collections.meta_title'),
twitterTitle: () => $t('collections.meta_title'),
description: () => $t('collections.meta_description'),
ogDescription: () => $t('collections.meta_description'),
twitterDescription: () => $t('collections.meta_description'),
})

defineOgImage(
'Page.takumi',
{
title: () => $t('collections.title'),
description: () => $t('collections.tagline'),
},
{ alt: () => $t('collections.meta_description') },
)
</script>

<template>
<main class="container flex-1 flex flex-col gap-8 py-12 sm:py-16 w-full">
<header class="flex flex-wrap items-start justify-between gap-4">
<h1 class="font-mono text-3xl sm:text-4xl font-medium">{{ heading }}</h1>
<div class="flex flex-col gap-4">
<h1 class="font-mono text-3xl sm:text-4xl font-medium">{{ heading }}</h1>
<p v-if="isBrowsing" class="text-fg-muted text-lg">{{ $t('collections.tagline') }}</p>
<CollectionYourData v-if="isBrowsing" />
</div>
<LinkBase
variant="button-primary"
classicon="i-lucide:plus"
Expand All @@ -68,6 +97,20 @@ useSeoMeta({
</LinkBase>
</header>

<nav v-if="isBrowsing" :aria-label="$t('collections.sort_label')">
<ButtonGroup>
<LinkBase
v-for="option in SORTS"
:key="option"
:variant="sort === option ? 'button-primary' : 'button-secondary'"
:to="{ name: 'collections', query: option === 'recent' ? {} : { sort: option } }"
:aria-current="sort === option ? 'page' : undefined"
>
{{ sortLabels[option] }}
</LinkBase>
</ButtonGroup>
</nav>

<CollectionGrid
:items="data?.items ?? []"
:pending="status === 'pending'"
Expand Down
3 changes: 2 additions & 1 deletion app/pages/collections/new.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,15 @@ async function save(body: CollectionBody) {
await navigateTo({
name: 'collections-identifier-rkey',
params: { identifier: user.value?.handle ?? created.did, rkey: created.rkey },
query: { created: '1' },
})
}

const router = useRouter()
const canGoBack = useCanGoBack()
function cancel() {
if (canGoBack.value) router.back()
else navigateTo('/')
else navigateTo({ name: 'collections' })
}

useSeoMeta({
Expand Down
3 changes: 2 additions & 1 deletion docs/content/2.guide/1.features.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ npmx.dev fetches repository statistics (stars, forks) from these git hosting pla

Collections are curated, ordered lists of npm packages, with an optional note on each one. Anyone with an Atmosphere account can make them, and they're stored in that account, not on npmx. See [Collections](/integrations/collections) for building with them from other apps.

- **Browse** - Find collections at [`/collections`](https://npmx.dev/collections), sorted by most recent, most liked, or most followed
- **Create and edit** - Name a collection, describe it, and add, reorder, annotate, or remove up to 1,000 items
- **Add from anywhere** - Add a package to one or more collections, or start a new one, from its package page, from a selection of search results, or with **Add to collection** in the command palette
- **Include other collections** - Add a collection's link to another collection to show it as a linked entry with its author and size
Expand All @@ -124,7 +125,7 @@ Collections are curated, ordered lists of npm packages, with an optional note on
## Use community signals

- **Package likes** - Like packages with an npmx social profile
- **Collection likes and follows** - Like and follow collections
- **Collection likes and follows** - Like and follow collections; browse ranks the most liked and most followed
- **Likes leaderboard** - Browse the most-liked packages across npmx

## Access related resources
Expand Down
4 changes: 2 additions & 2 deletions docs/content/2.guide/2.keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ Press `⌘K` on macOS, or `Ctrl+K` on Windows and Linux, to open the command pal

The command palette lets you:

- Jump between major pages like home, compare, settings, and docs
- Jump between major pages like home, compare, collections, settings, and docs
- Open package-specific surfaces like the main page, docs, code, and diff views
- Find your own collections by name, add the current package to a collection, and like, follow, edit or duplicate the collection you're viewing
- Find your own collections by name, add the current package to a collection, and like, follow, or edit the collection you're viewing
- Switch settings such as language, accent color, and background theme
- Open external resources like docs, chat, source, and package links

Expand Down
Loading
Loading