Skip to content

Commit 1fd086a

Browse files
ShroXdghostdevvautofix-ci[bot]
authored
feat: add animation to like button (#2082)
Co-authored-by: Willow (GHOST) <git@willow.sh> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 022d207 commit 1fd086a

File tree

3 files changed

+263
-102
lines changed

3 files changed

+263
-102
lines changed

app/components/Package/Header.vue

Lines changed: 1 addition & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
<script setup lang="ts">
22
import type { RouteLocationRaw } from 'vue-router'
33
import { SCROLL_TO_TOP_THRESHOLD } from '~/composables/useScrollToTop'
4-
import { useModal } from '~/composables/useModal'
5-
import { useAtproto } from '~/composables/atproto/useAtproto'
6-
import { togglePackageLike } from '~/utils/atproto/likes'
74
import { isEditableElement } from '~/utils/input'
85
96
const props = defineProps<{
@@ -64,7 +61,6 @@ const { y: scrollY } = useScroll(window)
6461
const showScrollToTop = computed(() => scrollY.value > SCROLL_TO_TOP_THRESHOLD)
6562
6663
const packageName = computed(() => props.pkg?.name ?? '')
67-
const compactNumberFormatter = useCompactNumberFormatter()
6864
6965
const { copied: copiedPkgName, copy: copyPkgName } = useClipboard({
7066
source: packageName,
@@ -178,70 +174,6 @@ onKeyStroke(
178174
{ dedupe: true },
179175
)
180176
181-
//atproto
182-
// TODO: Maybe set this where it's not loaded here every load?
183-
const { user } = useAtproto()
184-
185-
const authModal = useModal('auth-modal')
186-
187-
const { data: likesData, status: likeStatus } = useFetch(
188-
() => `/api/social/likes/${packageName.value}`,
189-
{
190-
default: () => ({ totalLikes: 0, userHasLiked: false }),
191-
server: false,
192-
},
193-
)
194-
195-
const isLoadingLikeData = computed(
196-
() => likeStatus.value === 'pending' || likeStatus.value === 'idle',
197-
)
198-
199-
const isLikeActionPending = shallowRef(false)
200-
201-
const likeAction = async () => {
202-
if (user.value?.handle == null) {
203-
authModal.open()
204-
return
205-
}
206-
207-
if (isLikeActionPending.value) return
208-
209-
const currentlyLiked = likesData.value?.userHasLiked ?? false
210-
const currentLikes = likesData.value?.totalLikes ?? 0
211-
212-
// Optimistic update
213-
likesData.value = {
214-
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
215-
userHasLiked: !currentlyLiked,
216-
}
217-
218-
isLikeActionPending.value = true
219-
220-
try {
221-
const result = await togglePackageLike(packageName.value, currentlyLiked, user.value?.handle)
222-
223-
isLikeActionPending.value = false
224-
225-
if (result.success) {
226-
// Update with server response
227-
likesData.value = result.data
228-
} else {
229-
// Revert on error
230-
likesData.value = {
231-
totalLikes: currentLikes,
232-
userHasLiked: currentlyLiked,
233-
}
234-
}
235-
} catch {
236-
// Revert on error
237-
likesData.value = {
238-
totalLikes: currentLikes,
239-
userHasLiked: currentlyLiked,
240-
}
241-
isLikeActionPending.value = false
242-
}
243-
}
244-
245177
const fundingUrl = computed(() => {
246178
let funding = props.displayVersion?.funding
247179
if (Array.isArray(funding)) funding = funding[0]
@@ -287,40 +219,7 @@ const fundingUrl = computed(() => {
287219
>
288220
<span class="max-sm:sr-only">{{ $t('package.links.compare_this_package') }}</span>
289221
</LinkBase>
290-
<!-- Package likes -->
291-
<TooltipApp
292-
:text="
293-
isLoadingLikeData
294-
? $t('common.loading')
295-
: likesData?.userHasLiked
296-
? $t('package.likes.unlike')
297-
: $t('package.likes.like')
298-
"
299-
position="bottom"
300-
class="items-center"
301-
strategy="fixed"
302-
>
303-
<ButtonBase
304-
@click="likeAction"
305-
size="medium"
306-
:aria-label="
307-
likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like')
308-
"
309-
:aria-pressed="likesData?.userHasLiked"
310-
:classicon="
311-
likesData?.userHasLiked ? 'i-lucide:heart-minus text-red-500' : 'i-lucide:heart-plus'
312-
"
313-
>
314-
<span
315-
v-if="isLoadingLikeData"
316-
class="i-svg-spinners:ring-resize w-3 h-3 my-0.5"
317-
aria-hidden="true"
318-
/>
319-
<span v-else>
320-
{{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}
321-
</span>
322-
</ButtonBase>
323-
</TooltipApp>
222+
<PackageLikes :packageName />
324223

325224
<LinkBase
326225
variant="button-secondary"

app/components/Package/Likes.vue

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
<script lang="ts" setup>
2+
import { useModal } from '~/composables/useModal'
3+
import { useAtproto } from '~/composables/atproto/useAtproto'
4+
import { togglePackageLike } from '~/utils/atproto/likes'
5+
6+
const props = defineProps<{
7+
packageName: string
8+
}>()
9+
10+
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
11+
12+
const likeAnimKey = shallowRef(0)
13+
const showLikeFloat = shallowRef(false)
14+
const likeFloatKey = shallowRef(0)
15+
let likeFloatTimer: ReturnType<typeof setTimeout> | null = null
16+
17+
const heartAnimStyle = computed(() => {
18+
if (likeAnimKey.value === 0 || prefersReducedMotion.value) return {}
19+
return {
20+
animation: likesData.value?.userHasLiked
21+
? 'heart-spring 0.55s cubic-bezier(0.34,1.56,0.64,1) forwards'
22+
: 'heart-unlike 0.3s ease forwards',
23+
}
24+
})
25+
26+
//atproto
27+
// TODO: Maybe set this where it's not loaded here every load?
28+
const { user } = useAtproto()
29+
30+
const authModal = useModal('auth-modal')
31+
const compactNumberFormatter = useCompactNumberFormatter()
32+
33+
const { data: likesData, status: likeStatus } = useFetch(
34+
() => `/api/social/likes/${props.packageName}`,
35+
{
36+
default: () => ({ totalLikes: 0, userHasLiked: false }),
37+
server: false,
38+
},
39+
)
40+
41+
const isLoadingLikeData = computed(
42+
() => likeStatus.value === 'pending' || likeStatus.value === 'idle',
43+
)
44+
45+
const isLikeActionPending = shallowRef(false)
46+
47+
const likeAction = async () => {
48+
if (user.value?.handle == null) {
49+
authModal.open()
50+
return
51+
}
52+
53+
if (isLikeActionPending.value) return
54+
55+
const currentlyLiked = likesData.value?.userHasLiked ?? false
56+
const currentLikes = likesData.value?.totalLikes ?? 0
57+
58+
likeAnimKey.value++
59+
60+
if (!currentlyLiked && !prefersReducedMotion.value) {
61+
if (likeFloatTimer !== null) {
62+
clearTimeout(likeFloatTimer)
63+
likeFloatTimer = null
64+
}
65+
likeFloatKey.value++
66+
showLikeFloat.value = true
67+
likeFloatTimer = setTimeout(() => {
68+
showLikeFloat.value = false
69+
likeFloatTimer = null
70+
}, 850)
71+
}
72+
73+
// Optimistic update
74+
likesData.value = {
75+
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
76+
userHasLiked: !currentlyLiked,
77+
}
78+
79+
isLikeActionPending.value = true
80+
81+
try {
82+
const result = await togglePackageLike(props.packageName, currentlyLiked, user.value?.handle)
83+
84+
isLikeActionPending.value = false
85+
86+
if (result.success) {
87+
// Update with server response
88+
likesData.value = result.data
89+
} else {
90+
// Revert on error
91+
likesData.value = {
92+
totalLikes: currentLikes,
93+
userHasLiked: currentlyLiked,
94+
}
95+
}
96+
} catch {
97+
// Revert on error
98+
likesData.value = {
99+
totalLikes: currentLikes,
100+
userHasLiked: currentlyLiked,
101+
}
102+
isLikeActionPending.value = false
103+
}
104+
}
105+
</script>
106+
107+
<template>
108+
<TooltipApp
109+
:text="
110+
isLoadingLikeData
111+
? $t('common.loading')
112+
: likesData?.userHasLiked
113+
? $t('package.likes.unlike')
114+
: $t('package.likes.like')
115+
"
116+
position="bottom"
117+
class="items-center"
118+
strategy="fixed"
119+
>
120+
<div :class="$style.likeWrapper">
121+
<span v-if="showLikeFloat" :key="likeFloatKey" aria-hidden="true" :class="$style.likeFloat"
122+
>+1</span
123+
>
124+
<ButtonBase
125+
@click="likeAction"
126+
size="medium"
127+
:aria-label="
128+
likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like')
129+
"
130+
:aria-pressed="likesData?.userHasLiked"
131+
>
132+
<span
133+
:key="likeAnimKey"
134+
:class="
135+
likesData?.userHasLiked
136+
? 'i-lucide:heart-minus fill-red-500 text-red-500'
137+
: 'i-lucide:heart-plus'
138+
"
139+
:style="heartAnimStyle"
140+
aria-hidden="true"
141+
class="inline-block w-4 h-4"
142+
/>
143+
<span
144+
v-if="isLoadingLikeData"
145+
class="i-svg-spinners:ring-resize w-3 h-3 my-0.5"
146+
aria-hidden="true"
147+
/>
148+
<span v-else>
149+
{{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}
150+
</span>
151+
</ButtonBase>
152+
</div>
153+
</TooltipApp>
154+
</template>
155+
156+
<style module>
157+
.likeWrapper {
158+
position: relative;
159+
display: inline-flex;
160+
}
161+
162+
.likeFloat {
163+
position: absolute;
164+
top: 0;
165+
left: 50%;
166+
font-size: 12px;
167+
font-weight: 600;
168+
color: var(--color-red-500, #ef4444);
169+
pointer-events: none;
170+
white-space: nowrap;
171+
animation: float-up 0.75s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
172+
}
173+
174+
@media (prefers-reduced-motion: reduce) {
175+
.likeFloat {
176+
display: none;
177+
}
178+
}
179+
180+
@keyframes float-up {
181+
0% {
182+
opacity: 0;
183+
transform: translateX(-50%) translateY(0);
184+
}
185+
15% {
186+
opacity: 1;
187+
transform: translateX(-50%) translateY(-4px);
188+
}
189+
80% {
190+
opacity: 1;
191+
transform: translateX(-50%) translateY(-20px);
192+
}
193+
100% {
194+
opacity: 0;
195+
transform: translateX(-50%) translateY(-28px);
196+
}
197+
}
198+
</style>
199+
200+
<style>
201+
@keyframes heart-spring {
202+
0% {
203+
transform: scale(1);
204+
}
205+
15% {
206+
transform: scale(0.78);
207+
}
208+
45% {
209+
transform: scale(1.55);
210+
}
211+
65% {
212+
transform: scale(0.93);
213+
}
214+
80% {
215+
transform: scale(1.1);
216+
}
217+
100% {
218+
transform: scale(1);
219+
}
220+
}
221+
222+
@keyframes heart-unlike {
223+
0% {
224+
transform: scale(1);
225+
}
226+
30% {
227+
transform: scale(0.85);
228+
}
229+
60% {
230+
transform: scale(1.05);
231+
}
232+
100% {
233+
transform: scale(1);
234+
}
235+
}
236+
237+
@media (prefers-reduced-motion: reduce) {
238+
@keyframes heart-spring {
239+
from,
240+
to {
241+
transform: scale(1);
242+
}
243+
}
244+
@keyframes heart-unlike {
245+
from,
246+
to {
247+
transform: scale(1);
248+
}
249+
}
250+
}
251+
</style>

0 commit comments

Comments
 (0)