Skip to content

Commit 4294ada

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 4294ada

File tree

3 files changed

+270
-102
lines changed

3 files changed

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

0 commit comments

Comments
 (0)