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
52 changes: 52 additions & 0 deletions app/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
37 changes: 37 additions & 0 deletions app/components/Collection/FollowButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { ReactionState } from '#shared/types/social'

const props = defineProps<{
identifier: string
rkey: string
}>()

const follows = defineModel<ReactionState>({ required: true })
const { toggle } = useCollectionReaction(follows, 'follow', () => props)

const compactNumberFormatter = useCompactNumberFormatter()
const label = computed(() =>
follows.value.viewerHas ? $t('collections.unfollow_label') : $t('collections.follow_label'),
)

// For the page's command palette entries
defineExpose({ toggle })
</script>

<template>
<!-- No aria-pressed: the visible label already changes between Follow and Following -->
<TooltipApp :text="label" position="bottom" strategy="fixed">
<ButtonBase
:classicon="follows.viewerHas ? 'i-lucide:bell-ring' : 'i-lucide:bell-plus'"
@click="toggle"
>
{{ follows.viewerHas ? $t('collections.following') : $t('collections.follow') }}
<span class="text-fg-muted" aria-hidden="true">
{{ compactNumberFormatter.format(follows.total) }}
</span>
<span class="sr-only">
{{ $t('collections.followers', { count: $n(follows.total) }, follows.total) }}
</span>
</ButtonBase>
</TooltipApp>
</template>
57 changes: 57 additions & 0 deletions app/components/Collection/LikeButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { ReactionState } from '#shared/types/social'

const props = defineProps<{
identifier: string
rkey: string
}>()

const likes = defineModel<ReactionState>({ required: true })
const { toggle: toggleLike } = useCollectionReaction(likes, 'like', () => props)

const compactNumberFormatter = useCompactNumberFormatter()
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
// Bumped per click so the heart animates again; 0 means no animation on first render
const animKey = shallowRef(0)
const heartAnimStyle = computed(() => {
if (animKey.value === 0 || prefersReducedMotion.value) return {}
return {
animation: likes.value.viewerHas
? 'heart-spring 0.55s cubic-bezier(0.34,1.56,0.64,1) forwards'
: 'heart-unlike 0.3s ease forwards',
}
})

const label = computed(() =>
likes.value.viewerHas ? $t('collections.unlike') : $t('collections.like'),
)

async function toggle() {
animKey.value++
await toggleLike()
}

// For the page's command palette entries
defineExpose({ toggle })
</script>

<template>
<!-- No aria-pressed: the label already changes between Like and Unlike -->
<TooltipApp :text="label" position="bottom" strategy="fixed">
<ButtonBase @click="toggle">
<span
:key="animKey"
:class="
likes.viewerHas ? 'i-lucide:heart-minus fill-red-500 text-red-500' : 'i-lucide:heart-plus'
"
:style="heartAnimStyle"
class="inline-block size-4"
aria-hidden="true"
/>
<span aria-hidden="true">{{ compactNumberFormatter.format(likes.total) }}</span>
<span class="sr-only">
{{ label }}, {{ $t('collections.like_count', { count: $n(likes.total) }, likes.total) }}
</span>
</ButtonBase>
</TooltipApp>
</template>
53 changes: 0 additions & 53 deletions app/components/Package/Likes.vue
Original file line number Diff line number Diff line change
Expand Up @@ -251,56 +251,3 @@ const likeAction = async () => {
}
}
</style>

<style>
@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);
}
}
}
</style>
46 changes: 46 additions & 0 deletions app/composables/useCollectionReaction.ts
Original file line number Diff line number Diff line change
@@ -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<ReactionState>,
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 }
}
10 changes: 9 additions & 1 deletion app/pages/collections/[identifier]/[rkey].stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { pageDecorator } from '../../../../.storybook/decorators'
import {
mockCollectionHandle,
mockCollectionHandler,
mockCollectionFollowHandlers,
mockCollectionLikeHandlers,
mockCollectionRkey,
mockPackageCardHandlers,
} from '../../../storybook/mocks/handlers/collections'
Expand All @@ -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],
Expand Down
78 changes: 73 additions & 5 deletions app/pages/collections/[identifier]/[rkey].vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { CollectionView } from '#shared/types/social'
import type { CollectionView, ReactionState } from '#shared/types/social'
import type { CommandPaletteContextCommandInput } from '~/types/command-palette'
import {
collectionPath,
Expand Down Expand Up @@ -39,6 +39,17 @@ const { data: packageCards } = usePackagesByName(packageNames)
const { user } = useAtproto()
const isOwner = computed(() => !!user.value?.did && user.value.did === collection.value?.did)

// useFetch data is a shallow ref, so replace rather than mutate
const reactionModel = (key: 'likes' | 'follows') =>
computed<ReactionState>({
get: () => collection.value?.[key] ?? { total: 0, viewerHas: false },
set: value => {
if (collection.value) collection.value = { ...collection.value, [key]: value }
},
})
const likes = reactionModel('likes')
const follows = reactionModel('follows')

const { polite } = useAnnouncer()
const { close: closeCommandPalette } = useCommandPalette()
const editor = useTemplateRef('editor')
Expand Down Expand Up @@ -112,21 +123,64 @@ const duplicateRoute = computed(() => ({
query: { from: `${collection.value?.did}/${collection.value?.rkey}` },
}))

const { announce } = useCommandPalette()
const likeButton = useTemplateRef('likeButton')
const followButton = useTemplateRef('followButton')
useCommandPaletteContextCommands(
computed((): CommandPaletteContextCommandInput[] => {
const current = collection.value
if (!current) return []
const keywords = [current.name, current.handle]
const commands: CommandPaletteContextCommandInput[] = [
{
id: 'collection-duplicate',
id: 'collection-like',
group: 'actions',
label: $t('collections.duplicate'),
label: current.likes.viewerHas ? $t('collections.unlike') : $t('collections.like'),
keywords,
iconClass: 'i-lucide:copy',
to: duplicateRoute.value,
iconClass: current.likes.viewerHas ? 'i-lucide:heart-minus' : 'i-lucide:heart-plus',
action: async () => {
const before = likes.value.viewerHas
await likeButton.value?.toggle()
// Unchanged when signed out (the sign-in dialog opened instead) or when it failed
if (likes.value.viewerHas === before) return
announce(
before
? $t('collections.reaction_announcements.unliked')
: $t('collections.reaction_announcements.liked'),
)
},
},
]
if (!isOwner.value) {
commands.push({
id: 'collection-follow',
group: 'actions',
label: current.follows.viewerHas
? $t('collections.unfollow_label')
: $t('collections.follow_label'),
keywords,
iconClass: current.follows.viewerHas ? 'i-lucide:bell-ring' : 'i-lucide:bell-plus',
action: async () => {
const before = follows.value.viewerHas
await followButton.value?.toggle()
// Unchanged when signed out (the sign-in dialog opened instead) or when it failed
if (follows.value.viewerHas === before) return
announce(
before
? $t('collections.reaction_announcements.unfollowed')
: $t('collections.reaction_announcements.followed'),
)
},
})
}
commands.push({
id: 'collection-duplicate',
group: 'actions',
label: $t('collections.duplicate'),
keywords,
iconClass: 'i-lucide:copy',
to: duplicateRoute.value,
})
if (isOwner.value && !isEditing.value) {
commands.push(
{
Expand Down Expand Up @@ -197,6 +251,20 @@ defineOgImage(
<p v-if="collection.description" class="text-fg-muted">{{ collection.description }}</p>

<div class="flex flex-wrap items-center gap-3">
<CollectionLikeButton
ref="likeButton"
v-model="likes"
:identifier="collection.did"
:rkey="collection.rkey"
/>
<!-- Owners follow their own collections implicitly -->
<CollectionFollowButton
v-if="!isOwner"
ref="followButton"
v-model="follows"
:identifier="collection.did"
:rkey="collection.rkey"
/>
<LinkBase variant="button-secondary" classicon="i-lucide:copy" :to="duplicateRoute">
{{ $t('collections.duplicate') }}
</LinkBase>
Expand Down
Loading
Loading