Skip to content

Commit 1fef8da

Browse files
authored
feat: show badge on top liked packages, link to leaderboard (#2459)
1 parent 1cd4f82 commit 1fef8da

25 files changed

Lines changed: 1958 additions & 82 deletions

File tree

app/components/Package/Likes.vue

Lines changed: 122 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script lang="ts" setup>
2+
import type { PackageLikes } from '#shared/types/social'
23
import { useModal } from '~/composables/useModal'
34
import { useAtproto } from '~/composables/atproto/useAtproto'
45
import { togglePackageLike } from '~/utils/atproto/likes'
@@ -37,17 +38,33 @@ const { user } = useAtproto()
3738
const authModal = useModal('auth-modal')
3839
const compactNumberFormatter = useCompactNumberFormatter()
3940
40-
const { data: likesData, status: likeStatus } = useFetch(
41+
const { data: likesData, status: likeStatus } = useFetch<PackageLikes>(
4142
() => `/api/social/likes/${props.packageName}`,
4243
{
43-
default: () => ({ totalLikes: 0, userHasLiked: false }),
44+
default: () => ({
45+
totalLikes: 0,
46+
userHasLiked: false,
47+
topLikedRank: null,
48+
}),
4449
server: false,
4550
},
4651
)
47-
4852
const isLoadingLikeData = computed(
4953
() => likeStatus.value === 'pending' || likeStatus.value === 'idle',
5054
)
55+
const isPackageLiked = computed(() => likesData.value?.userHasLiked ?? false)
56+
const topLikedRank = computed(() => likesData.value?.topLikedRank ?? null)
57+
const likeButtonLabel = computed(() =>
58+
isPackageLiked.value ? $t('package.likes.unlike') : $t('package.likes.like'),
59+
)
60+
const likeTooltipLabel = computed(() =>
61+
isLoadingLikeData.value ? $t('common.loading') : likeButtonLabel.value,
62+
)
63+
const topLikedBadgeLabel = computed(() =>
64+
topLikedRank.value == null
65+
? ''
66+
: $t('package.likes.top_rank_link_label', { rank: topLikedRank.value }),
67+
)
5168
5269
const isLikeActionPending = shallowRef(false)
5370
@@ -61,6 +78,11 @@ const likeAction = async () => {
6178
6279
const currentlyLiked = likesData.value?.userHasLiked ?? false
6380
const currentLikes = likesData.value?.totalLikes ?? 0
81+
const previousLikesState: PackageLikes = {
82+
totalLikes: currentLikes,
83+
userHasLiked: currentlyLiked,
84+
topLikedRank: topLikedRank.value,
85+
}
6486
6587
likeAnimKey.value++
6688
@@ -79,6 +101,7 @@ const likeAction = async () => {
79101
80102
// Optimistic update
81103
likesData.value = {
104+
...previousLikesState,
82105
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
83106
userHasLiked: !currentlyLiked,
84107
}
@@ -87,86 +110,77 @@ const likeAction = async () => {
87110
88111
try {
89112
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-
}
113+
likesData.value = result.success
114+
? {
115+
...previousLikesState,
116+
...result.data,
117+
topLikedRank: result.data.topLikedRank ?? previousLikesState.topLikedRank,
118+
}
119+
: previousLikesState
103120
} catch {
104-
// Revert on error
105-
likesData.value = {
106-
totalLikes: currentLikes,
107-
userHasLiked: currentlyLiked,
108-
}
121+
likesData.value = previousLikesState
122+
} finally {
109123
isLikeActionPending.value = false
110124
}
111125
}
112126
</script>
113127

114128
<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="md"
134-
:aria-label="
135-
likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like')
136-
"
137-
:aria-pressed="likesData?.userHasLiked"
129+
<div class="relative inline-flex items-center">
130+
<TooltipApp :text="likeTooltipLabel" position="bottom" class="items-center" strategy="fixed">
131+
<div class="relative inline-flex">
132+
<span v-if="showLikeFloat" :key="likeFloatKey" aria-hidden="true" class="like-float"
133+
>+1</span
134+
>
135+
<ButtonBase
136+
@click="likeAction"
137+
size="md"
138+
:aria-label="likeButtonLabel"
139+
:aria-pressed="isPackageLiked"
140+
>
141+
<span
142+
:key="likeAnimKey"
143+
:class="
144+
isPackageLiked
145+
? 'i-lucide:heart-minus fill-red-500 text-red-500'
146+
: 'i-lucide:heart-plus'
147+
"
148+
:style="heartAnimStyle"
149+
aria-hidden="true"
150+
class="inline-block w-4 h-4"
151+
/>
152+
<span
153+
v-if="isLoadingLikeData"
154+
class="i-svg-spinners:ring-resize w-3 h-3 my-0.5"
155+
aria-hidden="true"
156+
/>
157+
<span v-else>{{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}</span>
158+
</ButtonBase>
159+
</div>
160+
</TooltipApp>
161+
162+
<TooltipApp
163+
v-if="topLikedRank != null"
164+
:text="$t('package.likes.top_rank_tooltip', { rank: topLikedRank })"
165+
position="left"
166+
:offset="8"
167+
strategy="fixed"
168+
class="top-liked-badge-anchor"
169+
>
170+
<NuxtLink
171+
:to="{ name: 'leaderboard-likes' }"
172+
:aria-label="topLikedBadgeLabel"
173+
data-testid="top-liked-badge"
174+
class="top-liked-badge"
138175
>
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>
176+
<span>{{ $t('package.likes.top_rank_label', { rank: topLikedRank }) }}</span>
177+
</NuxtLink>
178+
</TooltipApp>
179+
</div>
161180
</template>
162181

163-
<style module>
164-
.likeWrapper {
165-
position: relative;
166-
display: inline-flex;
167-
}
168-
169-
.likeFloat {
182+
<style scoped>
183+
.like-float {
170184
position: absolute;
171185
top: 0;
172186
left: 50%;
@@ -178,8 +192,42 @@ const likeAction = async () => {
178192
animation: float-up 0.75s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
179193
}
180194
195+
.top-liked-badge-anchor {
196+
position: absolute;
197+
inset-inline-end: -0.5rem;
198+
top: -0.4rem;
199+
z-index: 1;
200+
}
201+
202+
.top-liked-badge {
203+
display: inline-flex;
204+
align-items: center;
205+
justify-content: center;
206+
min-width: 1.25rem;
207+
padding: 0.125rem 0.375rem;
208+
border: 1px solid var(--bg);
209+
border-radius: 9999px;
210+
background: var(--accent);
211+
color: var(--bg);
212+
font-size: 0.6875rem;
213+
font-weight: 700;
214+
line-height: 1;
215+
text-decoration: none;
216+
box-shadow: 0 2px 6px color-mix(in oklab, var(--accent) 14%, transparent);
217+
transition: box-shadow 160ms ease;
218+
}
219+
220+
.top-liked-badge:hover {
221+
box-shadow: 0 4px 10px color-mix(in oklab, var(--accent) 18%, transparent);
222+
}
223+
224+
.top-liked-badge:focus-visible {
225+
outline: 2px solid var(--fg);
226+
outline-offset: 2px;
227+
}
228+
181229
@media (prefers-reduced-motion: reduce) {
182-
.likeFloat {
230+
.like-float {
183231
display: none;
184232
}
185233
}

0 commit comments

Comments
 (0)