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 app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ if (!isBlogPostRoute.value) {
</div>

<CommandPalette />
<CollectionAddToModal />

<AppFooter />

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

const { open } = useAddToCollection()
</script>

<template>
<ButtonBase classicon="i-lucide:bookmark-plus" @click="open(props.packages)">
<slot>{{ $t('collections.picker.title') }}</slot>
</ButtonBase>
</template>
24 changes: 24 additions & 0 deletions app/components/Collection/AddToModal.client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script setup lang="ts">
const { packages, openCount } = useAddToCollection()

const subtitle = computed(() =>
packages.value.length === 1
? packages.value[0]
: $t(
'collections.picker.packages',
{ count: $n(packages.value.length) },
packages.value.length,
),
)
</script>

<template>
<Modal
:id="ADD_TO_COLLECTION_MODAL_ID"
:modal-title="$t('collections.picker.title')"
:modal-subtitle="subtitle"
class="max-w-md"
>
<CollectionPicker v-if="packages.length" :key="openCount" :packages />
</Modal>
</template>
165 changes: 165 additions & 0 deletions app/components/Collection/Picker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<script setup lang="ts">
import type { OwnCollection } from '#shared/types/social'
import {
MAX_COLLECTION_ITEMS,
MAX_COLLECTION_NAME_LENGTH,
PACKAGE_SUBJECT_REF,
} from '#shared/utils/constants'

const props = defineProps<{
packages: string[]
}>()

const { collections, status, load, changeItems, create } = useOwnCollections()
const { user } = useAtproto()
// The session may still be loading when the picker mounts
watch(
() => user.value?.did,
() => load({ force: true }),
{ immediate: true },
)

const subjectRefs = computed(() => props.packages.map(PACKAGE_SUBJECT_REF))

function containedCount(collection: OwnCollection) {
return subjectRefs.value.filter(ref => collection.subjectRefs.includes(ref)).length
}
const containsAll = (collection: OwnCollection) =>
containedCount(collection) === subjectRefs.value.length
const containsSome = (collection: OwnCollection) =>
containedCount(collection) > 0 && !containsAll(collection)
const isFull = (collection: OwnCollection) =>
!containsAll(collection) &&
collection.subjectRefs.length + subjectRefs.value.length - containedCount(collection) >
MAX_COLLECTION_ITEMS

const pendingUris = ref(new Set<string>())
const failedUri = shallowRef<string | null>(null)

// Not disabled while pending: disabling the focused box would drop focus out of the dialog
async function toggle(collection: OwnCollection, event: Event) {
const input = event.target as HTMLInputElement
if (pendingUris.value.has(collection.uri)) return
pendingUris.value.add(collection.uri)
failedUri.value = null
try {
await changeItems(
collection,
containsAll(collection) ? { remove: subjectRefs.value } : { add: subjectRefs.value },
)
} catch {
failedUri.value = collection.uri
// The browser already flipped the box, and Vue won't re-patch an unchanged binding
input.checked = containsAll(collection)
input.indeterminate = containsSome(collection)
} finally {
pendingUris.value.delete(collection.uri)
}
}

const { polite } = useAnnouncer()
const newName = shallowRef('')
const isCreating = shallowRef(false)
const createError = shallowRef('')

async function submitNew() {
const name = newName.value.trim()
if (!name || isCreating.value) return
isCreating.value = true
createError.value = ''
try {
await create(name, subjectRefs.value)
newName.value = ''
polite($t('collections.picker.created', { name }))
} catch {
createError.value = $t('collections.picker.create_error')
} finally {
isCreating.value = false
}
}

const newNameId = useId()
const fullReasonId = useId()
</script>

<template>
<div class="flex flex-col gap-4">
<!-- Also when a refresh fails, since the list shown may then be out of date -->
<p v-if="status === 'error'" role="alert" class="text-sm text-fg-muted">
{{ $t('collections.load_error') }}
</p>
<div v-if="!collections && status === 'pending'" class="flex flex-col gap-2">
<SkeletonBlock v-for="i in 3" :key="i" class="h-10 rounded-lg" />
</div>
<p v-else-if="collections?.length === 0" class="text-sm text-fg-muted">
{{ $t('collections.picker.none_yet') }}
</p>
<ul
v-else-if="collections"
class="m-0 flex max-h-80 list-none flex-col gap-1 overflow-y-auto p-0"
>
<li v-for="collection in collections" :key="collection.uri">
<label
class="flex items-center gap-3 rounded-lg px-3 py-2 transition-colors duration-200"
:class="isFull(collection) ? 'opacity-60 cursor-not-allowed' : 'hover:bg-bg-muted'"
>
<input
type="checkbox"
class="size-4 shrink-0 accent-fg"
:checked="containsAll(collection)"
:indeterminate="containsSome(collection)"
:disabled="isFull(collection)"
:aria-describedby="isFull(collection) ? fullReasonId : undefined"
@change="toggle(collection, $event)"
/>
<span class="min-w-0 flex-1 truncate font-mono text-sm">{{ collection.name }}</span>
<TooltipApp
v-if="isFull(collection)"
:text="$t('collections.picker.full_reason', { max: $n(MAX_COLLECTION_ITEMS) })"
>
<span class="shrink-0 text-xs text-fg-subtle">{{ $t('collections.picker.full') }}</span>
</TooltipApp>
<span v-else class="shrink-0 text-xs text-fg-subtle">
{{
$t(
'collections.item_count',
{ count: $n(collection.itemCount) },
collection.itemCount,
)
}}
</span>
</label>
<p
v-if="failedUri === collection.uri"
role="alert"
class="px-3 font-mono text-xs text-red-700 dark:text-red-400"
>
{{ $t('collections.picker.save_error') }}
</p>
</li>
</ul>
<p :id="fullReasonId" class="sr-only">
{{ $t('collections.picker.full_reason', { max: $n(MAX_COLLECTION_ITEMS) }) }}
</p>

<form class="flex flex-col gap-2 border-t border-border pt-4" @submit.prevent="submitNew">
<label :for="newNameId" class="text-sm">{{ $t('collections.new') }}</label>
<div class="flex gap-2">
<InputBase
:id="newNameId"
v-model="newName"
class="min-w-0 flex-1"
:maxlength="MAX_COLLECTION_NAME_LENGTH"
:no-correct="false"
:placeholder="$t('collections.picker.name_placeholder')"
/>
<ButtonBase type="submit" variant="primary" :disabled="isCreating || !newName.trim()">
{{ $t('collections.picker.create') }}
</ButtonBase>
</div>
<p v-if="createError" role="alert" class="font-mono text-sm text-red-700 dark:text-red-400">
{{ createError }}
</p>
</form>
</div>
</template>
17 changes: 10 additions & 7 deletions app/components/Package/ActionBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,16 @@ onKeyStroke(
</button>
</div>

<LinkBase
:to="{ name: 'compare', query: { packages: selectedPackagesParam } }"
variant="button-secondary"
classicon="i-lucide:git-compare"
>
{{ $t('package.links.compare') }}
</LinkBase>
<div class="flex items-center gap-2">
<LinkBase
:to="{ name: 'compare', query: { packages: selectedPackagesParam } }"
variant="button-secondary"
classicon="i-lucide:git-compare"
>
{{ $t('package.links.compare') }}
</LinkBase>
<CollectionAddButton :packages="selectedPackages" />
</div>
</div>
</section>
</Transition>
Expand Down
3 changes: 3 additions & 0 deletions app/components/Package/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,9 @@ useShortcuts({
<!-- Package metrics -->
<div class="flex gap-2 flex-wrap items-stretch">
<PackageLikes :packageName />
<CollectionAddButton :packages="[packageName]">
<span class="max-sm:sr-only">{{ $t('collections.picker.title') }}</span>
</CollectionAddButton>

<LinkBase
variant="button-secondary"
Expand Down
1 change: 1 addition & 0 deletions app/components/Package/SelectionView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const { data, pending } = useAsyncData(
>
{{ $t('package.links.compare') }}
</LinkBase>
<CollectionAddButton :packages="selectedPackages" />
</div>
</header>

Expand Down
23 changes: 23 additions & 0 deletions app/composables/useAddToCollection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const ADD_TO_COLLECTION_MODAL_ID = 'add-to-collection-modal'

/** Opens the add-to-collection picker for some packages, or sign-in when signed out. */
export function useAddToCollection() {
const packages = useState<string[]>('add-to-collection:packages', () => [])
// Remounts the picker on every open so it starts from fresh collections
const openCount = useState('add-to-collection:open-count', () => 0)
const { user } = useAtproto()
const authModal = useModal('auth-modal')
const modal = useModal(ADD_TO_COLLECTION_MODAL_ID)

function open(packageNames: string[]) {
if (!user.value?.handle) {
authModal.open()
return
}
packages.value = packageNames
openCount.value++
modal.open()
}

return { packages, openCount, open }
}
13 changes: 13 additions & 0 deletions app/composables/useCommandPalettePackageCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function useCommandPalettePackageCommands(
const { t } = useI18n()
const route = useRoute()
const { announce, close } = useCommandPalette()
const { open: openAddToCollection } = useAddToCollection()

function isCurrentPackageCompare(packageName: string) {
const packages = route.query.packages
Expand Down Expand Up @@ -143,6 +144,18 @@ export function useCommandPalettePackageCommands(
},
})

commands.push({
id: 'package-add-to-collection',
group: 'package',
label: t('command_palette.package.add_to_collection'),
keywords: [resolvedContext.packageName, t('collections.title')],
iconClass: 'i-lucide:bookmark-plus',
action: () => {
close()
openAddToCollection([resolvedContext.packageName])
},
})

if (resolvedContext.tarballUrl) {
commands.push({
id: 'package-download',
Expand Down
35 changes: 32 additions & 3 deletions app/composables/useOwnCollections.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { OwnCollection } from '#shared/types/social'
import { fetchOwnCollections } from '~/utils/atproto/collections'
import {
changeCollectionItems,
createCollection,
fetchOwnCollections,
} from '~/utils/atproto/collections'

/** The signed-in user's collections, for finding them from the command palette. */
/** The signed-in user's collections, shared by the add-to-collection picker and the palette. */
export function useOwnCollections() {
const { user } = useAtproto()
const state = useState<{ did: string; items: OwnCollection[] } | null>(
Expand All @@ -27,5 +31,30 @@ export function useOwnCollections() {
}
}

return { collections, status, load }
async function changeItems(
collection: OwnCollection,
change: { add?: string[]; remove?: string[] },
) {
const { subjectRefs, cid } = await changeCollectionItems(
collection.did,
collection.rkey,
change,
user.value?.handle,
)
// The server's answer is the record as written, so it can be applied as is
const items = state.value?.items.map(c =>
c.uri === collection.uri ? { ...c, cid, subjectRefs, itemCount: subjectRefs.length } : c,
)
if (state.value && items) state.value = { ...state.value, items }
}

async function create(name: string, subjectRefs: string[]) {
await createCollection(
{ name, items: subjectRefs.map(subjectRef => ({ subjectRef })) },
user.value?.handle,
)
await load({ force: true })
}

return { collections, status, load, changeItems, create }
}
16 changes: 16 additions & 0 deletions app/utils/atproto/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,22 @@ export function fetchOwnCollections() {
return $fetch<OwnCollection[]>('/api/social/collections/mine')
}

export function changeCollectionItems(
identifier: string,
rkey: string,
change: { add?: string[]; remove?: string[] },
userHandle?: string | null,
) {
return request(
() =>
$fetch<CollectionWriteResult & { subjectRefs: string[] }>(
`${collectionPath(identifier, rkey)}/items`,
{ method: 'POST', body: change },
),
userHandle,
)
}

export function fetchUserCollections(identifier: string, cursor?: string | null, limit = 20) {
return $fetch<{ cursor?: string; items: CollectionSummary[] }>(
`/api/social/collections/${encodeURIComponent(identifier)}`,
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 @@ -105,6 +105,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.

- **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
- **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
Expand Down
2 changes: 1 addition & 1 deletion docs/content/2.guide/2.keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The command palette lets you:

- Jump between major pages like home, compare, settings, and docs
- Open package-specific surfaces like the main page, docs, code, and diff views
- Find your own collections by name, 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, edit or duplicate 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