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
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ The command palette is a first-class navigation surface. When you add a new user
| `org` | `/org/:org` | `org` |
| `~username` | `/~:username` | `username` |
| `~username-orgs` | `/~:username/orgs` | `username` |
| `collections` | `/collections` | — |
| `collections-new` | `/collections/new` | — |
| `collections-identifier-rkey` | `/collections/:identifier/:rkey` | `identifier` (handle or DID), `rkey` |

Expand Down
20 changes: 9 additions & 11 deletions app/components/Collection/ItemList.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,23 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>

/** Packages with notes, a package missing from the registry, and a nested collection. */
/** Packages with notes, a package missing from the registry, a nested collection, and a hidden one. */
export const Default: Story = {}

/** Packages shown as cards once their metadata has loaded. */
export const WithPackageCards: Story = {
args: { packages: mockPackageCards },
}

/** An included collection that can't be shown. */
export const UnavailableCollection: Story = {
/** Every reason an included collection can't be shown. */
export const UnavailableCollections: Story = {
args: {
items: [
{
kind: 'collection',
subjectRef: 'at://did:plc:x/dev.npmx.collection/3m5k5baifas2z',
collection: null,
unavailableReason: 'unresolvable',
},
],
items: (['unresolvable', 'hidden'] as const).map(reason => ({
kind: 'collection' as const,
subjectRef: `at://did:plc:x/dev.npmx.collection/${reason}`,
collection: null,
unavailableReason: reason,
})),
},
}

Expand Down
10 changes: 8 additions & 2 deletions app/components/Collection/ItemList.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { NpmSearchResult } from '#shared/types'
import type { CollectionItemView } from '#shared/types/social'
import type { CollectionItemView, CollectionUnavailableReason } from '#shared/types/social'

const { packages } = defineProps<{
items: CollectionItemView[]
Expand All @@ -13,6 +13,12 @@ function cardResult(packageName: string) {
const result = packages?.get(packageName)
return result && { ...result, package: { ...result.package, keywords: undefined } }
}

// Static keys so the i18n unused-key check can see them
const unavailableText = (reason: CollectionUnavailableReason | undefined) =>
reason === 'hidden'
? $t('collections.unavailable.hidden')
: $t('collections.unavailable.unresolvable')
</script>

<template>
Expand Down Expand Up @@ -83,7 +89,7 @@ function cardResult(packageName: string) {
<CollectionItemNote v-if="item.note">{{ item.note }}</CollectionItemNote>
</div>
<p v-else class="font-mono text-sm text-fg-subtle">
{{ $t('collections.unavailable.unresolvable') }}
{{ unavailableText(item.unavailableReason) }}
</p>
</div>

Expand Down
26 changes: 26 additions & 0 deletions app/components/Package/CollectionsCount.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script setup lang="ts">
const props = defineProps<{
packageName: string
}>()

const compactNumberFormatter = useCompactNumberFormatter()

const { data } = useFetch<{ total?: number }>('/api/social/collections/containing', {
query: computed(() => ({ subject: PACKAGE_SUBJECT_REF(props.packageName), limit: 0 })),
default: () => ({ total: 0 }),
server: false,
})
const total = computed(() => data.value?.total ?? 0)
</script>

<template>
<LinkBase
v-if="total > 0"
variant="button-secondary"
classicon="i-lucide:library"
:to="{ name: 'collections', query: { package: packageName } }"
data-testid="package-collections-count"
>
{{ $t('collections.in_collections', { count: compactNumberFormatter.format(total) }, total) }}
</LinkBase>
</template>
1 change: 1 addition & 0 deletions app/components/Package/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ useShortcuts({
<!-- Package metrics -->
<div class="flex gap-2 flex-wrap items-stretch">
<PackageLikes :packageName />
<PackageCollectionsCount :packageName />
<CollectionAddButton :packages="[packageName]">
<span class="max-sm:sr-only">{{ $t('collections.picker.title') }}</span>
</CollectionAddButton>
Expand Down
15 changes: 15 additions & 0 deletions app/pages/collections/[identifier]/[rkey].vue
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ async function remove() {
await navigateTo({ name: 'profile-identity', params: { identity: handle } })
}

const compactNumberFormatter = useCompactNumberFormatter()
const duplicateRoute = computed(() => ({
name: 'collections-new' as const,
query: { from: `${collection.value?.did}/${collection.value?.rkey}` },
Expand Down Expand Up @@ -276,6 +277,20 @@ defineOgImage(
{{ $t('common.delete') }}
</ButtonBase>
</template>
<LinkBase
v-if="collection.includedInCount > 0"
variant="button-secondary"
classicon="i-lucide:library"
:to="{ name: 'collections', query: { includes: `${collection.did}/${collection.rkey}` } }"
>
{{
$t(
'collections.in_collections',
{ count: compactNumberFormatter.format(collection.includedInCount) },
collection.includedInCount,
)
}}
</LinkBase>
</div>
</header>

Expand Down
82 changes: 82 additions & 0 deletions app/pages/collections/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { CollectionSummary } from '#shared/types/social'
import { parseCollectionQuery } from '~/utils/atproto/collections'

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

const route = useRoute('collections')

// Collections containing a package, or including another collection
const packageName = computed(() =>
typeof route.query.package === 'string' ? route.query.package : '',
)
const includes = computed(() => parseCollectionQuery(route.query.includes))

// 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 { data, status, error } = await useFetch<Page>(endpoint, { query: baseQuery })

const isLoadingMore = shallowRef(false)
async function loadMore() {
const current = data.value
if (!current?.cursor || isLoadingMore.value) return
isLoadingMore.value = true
try {
const page = await $fetch<Page>(endpoint, {
query: { ...baseQuery.value, cursor: current.cursor },
})
// The query changed while loading, so this page belongs to the old one
if (data.value !== current) return
// useFetch data is a shallow ref, so replace rather than mutate
data.value = { ...current, cursor: page.cursor, items: [...current.items, ...page.items] }
} finally {
isLoadingMore.value = false
}
}

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

useSeoMeta({
title: heading,
ogTitle: heading,
twitterTitle: heading,
})
</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>
<LinkBase
variant="button-primary"
classicon="i-lucide:plus"
:to="{ name: 'collections-new' }"
>
{{ $t('collections.new') }}
</LinkBase>
</header>

<CollectionGrid
:items="data?.items ?? []"
:pending="status === 'pending'"
:loading-more="isLoadingMore"
:error="!!error"
:has-more="!!data?.cursor"
:empty-text="$t('collections.empty')"
:heading-level="2"
@load-more="loadMore"
/>
</main>
</template>
7 changes: 7 additions & 0 deletions app/storybook/mocks/handlers/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const mockCollection: CollectionView = {
...mockCollectionSummary,
likes: { total: 12, viewerHas: false },
follows: { total: 5, viewerHas: false },
includedInCount: 2,
items: [
{
kind: 'package',
Expand Down Expand Up @@ -77,6 +78,12 @@ export const mockCollection: CollectionView = {
itemCount: 2,
},
},
{
kind: 'collection',
subjectRef: 'at://did:plc:spammer/dev.npmx.collection/3m5k5baifas2c',
collection: null,
unavailableReason: 'hidden',
},
],
}

Expand Down
1 change: 1 addition & 0 deletions docs/content/2.guide/1.features.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ Collections are curated, ordered lists of npm packages, with an optional note on
- **Include other collections** - Add a collection's link to another collection to show it as a linked entry with its author and size
- **Duplicate** - Start a new collection from a copy of any collection
- **Like and follow** - Like a collection to recommend it, or follow it to keep up with it. Your own collections count as followed
- **Find related collections** - Package pages and collection pages show how many collections include them

## Explore users and organizations

Expand Down
27 changes: 14 additions & 13 deletions docs/content/2.guide/3.url-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,20 @@ npmx.dev also supports shorter, cleaner URLs:

npmx.dev keeps package exploration views in routes or query parameters so you can copy and share the exact state you are looking at.

| View | Example |
| ------------------- | --------------------------------- |
| Search results | `/search?q=vue` |
| Package comparison | `/compare?packages=react,preact` |
| Tools | `/tools` |
| A collection | `/collections/<handle>/<rkey>` |
| Dependency stats | `/tools/deps-stats` |
| Source file or line | `/package-code/vue/v/3.5.27/...` |
| Generated docs | `/package-docs/ufo/v/1.6.3` |
| Version diff | `/diff/vue/v/3.5.26...3.5.27` |
| Changelog | `/package-changelog/vue/v/3.5.27` |
| Timeline | `/package-timeline/vue/v/3.5.27` |
| Stats | `/package-stats/vue/v/3.5.27` |
| View | Example |
| -------------------------- | --------------------------------- |
| Search results | `/search?q=vue` |
| Package comparison | `/compare?packages=react,preact` |
| Tools | `/tools` |
| A collection | `/collections/<handle>/<rkey>` |
| Collections with a package | `/collections?package=vue` |
| Dependency stats | `/tools/deps-stats` |
| Source file or line | `/package-code/vue/v/3.5.27/...` |
| Generated docs | `/package-docs/ufo/v/1.6.3` |
| Version diff | `/diff/vue/v/3.5.26...3.5.27` |
| Changelog | `/package-changelog/vue/v/3.5.27` |
| Timeline | `/package-timeline/vue/v/3.5.27` |
| Stats | `/package-stats/vue/v/3.5.27` |

## Understand URL limitations

Expand Down
6 changes: 4 additions & 2 deletions docs/content/4.integrations/2.collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ navigation:

## Collections live in your account

A collection is an ordered list of npm packages with notes, stored as a `dev.npmx.collection` record in your own atproto repository. npmx.dev is one way to make and view them, not the only one. Write a record with that shape from any app, script or PDS, and it shows up on your npmx.dev profile.
A collection is an ordered list of npm packages with notes, stored as a `dev.npmx.collection` record in your own atproto repository. npmx.dev is one way to make and view them, not the only one. Write a record with that shape from any app, script or PDS, and it shows up on npmx.dev: on your profile and on the pages of the packages it contains.

Collections are public, like everything else in an atproto repository.

Expand Down Expand Up @@ -122,4 +122,6 @@ To edit a collection, use `com.atproto.repo.putRecord` with the same record key,
## When it shows up

- **Your profile** reads straight from your PDS, so it updates right away. **The collection page** does too for changes made on npmx.dev; changes from other apps can take up to 5 minutes.
- **Like counts** come from [Constellation](https://constellation.microcosm.blue/), usually within seconds.
- **Package pages** ("in N collections") and like counts come from [Constellation](https://constellation.microcosm.blue/), usually within seconds.

Collections from accounts that Bluesky's moderation service has labeled as spam or hidden don't appear on npmx.dev.
6 changes: 5 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -482,11 +482,15 @@
"delete_title": "Delete this collection?",
"delete_body": "This can't be undone.",
"delete_error": "Couldn't delete the collection. Please try again.",
"in_collections": "In {count} collection | In {count} collections",
"containing_package_title": "Collections with {name}",
"including_title": "Collections that include this one",
"includes": "Includes",
"package_unresolved": "Not found on the npm registry",
"unknown_item": "Unrecognized item",
"unavailable": {
"unresolvable": "This collection couldn't be loaded."
"unresolvable": "This collection couldn't be loaded.",
"hidden": "This collection is hidden."
},
"not_found": "Collection Not Found",
"not_found_message": "This collection doesn't exist, was deleted, or is hidden.",
Expand Down
6 changes: 5 additions & 1 deletion i18n/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -388,11 +388,15 @@
"delete_title": "Supprimer cette collection ?",
"delete_body": "Cette action est irrΓ©versible.",
"delete_error": "Impossible de supprimer la collection. Veuillez rΓ©essayer.",
"in_collections": "Dans {count} collection | Dans {count} collections",
"containing_package_title": "Collections avec {name}",
"including_title": "Collections qui incluent celle-ci",
"includes": "Inclut",
"package_unresolved": "Introuvable sur le registre npm",
"unknown_item": "Γ‰lΓ©ment non reconnu",
"unavailable": {
"unresolvable": "Impossible de charger cette collection."
"unresolvable": "Impossible de charger cette collection.",
"hidden": "Cette collection est masquΓ©e."
},
"not_found": "Collection introuvable",
"not_found_message": "Cette collection n'existe pas, a Γ©tΓ© supprimΓ©e ou est masquΓ©e.",
Expand Down
12 changes: 12 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1450,6 +1450,15 @@
"delete_error": {
"type": "string"
},
"in_collections": {
"type": "string"
},
"containing_package_title": {
"type": "string"
},
"including_title": {
"type": "string"
},
"includes": {
"type": "string"
},
Expand All @@ -1464,6 +1473,9 @@
"properties": {
"unresolvable": {
"type": "string"
},
"hidden": {
"type": "string"
}
},
"additionalProperties": false
Expand Down
23 changes: 23 additions & 0 deletions server/api/social/collections/containing.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* GET /api/social/collections/containing?subject=<subjectRef>&cursor=&limit=
*
* Collections whose items include `subject`, a package URL or a collection AT-URI.
* `limit=0` returns only the total.
*/
export default defineEventHandler(async event => {
const subject = getQuery(event).subject
if (typeof subject !== 'string' || !parseSubjectRef(subject)) {
throw createError({ status: 400, message: 'Invalid subject' })
}

try {
const utils = new CollectionsUtils()
if (getQuery(event).limit === '0') {
return { total: await utils.countCollectionsContaining(subject), items: [] }
}
const { cursor, limit } = getPaginationQuery(event)
return await utils.getCollectionsContaining(subject, limit, cursor)
} catch (error: unknown) {
handleApiError(error, { statusCode: 502, message: 'Failed to get containing collections' })
}
})
Loading
Loading