diff --git a/app/assets/main.css b/app/assets/main.css
index bce110f018..56e50038b1 100644
--- a/app/assets/main.css
+++ b/app/assets/main.css
@@ -401,3 +401,55 @@ html:has(dialog:modal) {
.vue-ui-pen-and-paper-group-actions {
background-color: transparent !important;
}
+
+/* Like button heart, shared by package and collection likes */
+@keyframes heart-spring {
+ 0% {
+ transform: scale(1);
+ }
+ 15% {
+ transform: scale(0.78);
+ }
+ 45% {
+ transform: scale(1.55);
+ }
+ 65% {
+ transform: scale(0.93);
+ }
+ 80% {
+ transform: scale(1.1);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+
+@keyframes heart-unlike {
+ 0% {
+ transform: scale(1);
+ }
+ 30% {
+ transform: scale(0.85);
+ }
+ 60% {
+ transform: scale(1.05);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ @keyframes heart-spring {
+ from,
+ to {
+ transform: scale(1);
+ }
+ }
+ @keyframes heart-unlike {
+ from,
+ to {
+ transform: scale(1);
+ }
+ }
+}
diff --git a/app/components/Collection/FollowButton.vue b/app/components/Collection/FollowButton.vue
new file mode 100644
index 0000000000..1d08b3a355
--- /dev/null
+++ b/app/components/Collection/FollowButton.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+ {{ follows.viewerHas ? $t('collections.following') : $t('collections.follow') }}
+
+ {{ compactNumberFormatter.format(follows.total) }}
+
+
+ {{ $t('collections.followers', { count: $n(follows.total) }, follows.total) }}
+
+
+
+
diff --git a/app/components/Collection/LikeButton.vue b/app/components/Collection/LikeButton.vue
new file mode 100644
index 0000000000..5561826618
--- /dev/null
+++ b/app/components/Collection/LikeButton.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+ {{ compactNumberFormatter.format(likes.total) }}
+
+ {{ label }}, {{ $t('collections.like_count', { count: $n(likes.total) }, likes.total) }}
+
+
+
+
diff --git a/app/components/Package/Likes.vue b/app/components/Package/Likes.vue
index e48f543e62..371886775f 100644
--- a/app/components/Package/Likes.vue
+++ b/app/components/Package/Likes.vue
@@ -251,56 +251,3 @@ const likeAction = async () => {
}
}
-
-
diff --git a/app/composables/useCollectionReaction.ts b/app/composables/useCollectionReaction.ts
new file mode 100644
index 0000000000..a9ce406bcd
--- /dev/null
+++ b/app/composables/useCollectionReaction.ts
@@ -0,0 +1,46 @@
+import type { Ref } from 'vue'
+import type { ReactionState } from '#shared/types/social'
+import { toggleCollectionReaction, type Reaction } from '~/utils/atproto/collections'
+
+/** Optimistic like or follow toggle for a collection; opens sign-in when signed out. */
+export function useCollectionReaction(
+ state: Ref,
+ reaction: Reaction,
+ target: () => { identifier: string; rkey: string },
+) {
+ const { user } = useAtproto()
+ const authModal = useModal('auth-modal')
+ const isPending = shallowRef(false)
+
+ async function toggle() {
+ const handle = user.value?.handle
+ if (handle == null) {
+ authModal.open()
+ return
+ }
+ if (isPending.value) return
+
+ const previous = state.value
+ state.value = {
+ total: previous.total + (previous.viewerHas ? -1 : 1),
+ viewerHas: !previous.viewerHas,
+ }
+ isPending.value = true
+ try {
+ const { identifier, rkey } = target()
+ state.value = await toggleCollectionReaction(
+ identifier,
+ rkey,
+ reaction,
+ previous.viewerHas,
+ handle,
+ )
+ } catch {
+ state.value = previous
+ } finally {
+ isPending.value = false
+ }
+ }
+
+ return { toggle }
+}
diff --git a/app/pages/collections/[identifier]/[rkey].stories.ts b/app/pages/collections/[identifier]/[rkey].stories.ts
index ea8b0976b0..7a328bec42 100644
--- a/app/pages/collections/[identifier]/[rkey].stories.ts
+++ b/app/pages/collections/[identifier]/[rkey].stories.ts
@@ -4,6 +4,8 @@ import { pageDecorator } from '../../../../.storybook/decorators'
import {
mockCollectionHandle,
mockCollectionHandler,
+ mockCollectionFollowHandlers,
+ mockCollectionLikeHandlers,
mockCollectionRkey,
mockPackageCardHandlers,
} from '../../../storybook/mocks/handlers/collections'
@@ -17,7 +19,13 @@ const meta = {
`/collections/${mockCollectionHandle}/${mockCollectionRkey}`,
),
beforeEach({ msw }) {
- msw.use(mockCollectionHandler, ...mockPackageCardHandlers, mockAuthSessionHandler())
+ msw.use(
+ mockCollectionHandler,
+ ...mockPackageCardHandlers,
+ ...mockCollectionLikeHandlers,
+ ...mockCollectionFollowHandlers,
+ mockAuthSessionHandler(),
+ )
},
parameters: { layout: 'fullscreen' },
decorators: [pageDecorator],
diff --git a/app/pages/collections/[identifier]/[rkey].vue b/app/pages/collections/[identifier]/[rkey].vue
index 8c24691d2b..36be808ef2 100644
--- a/app/pages/collections/[identifier]/[rkey].vue
+++ b/app/pages/collections/[identifier]/[rkey].vue
@@ -1,5 +1,5 @@