11<script lang="ts" setup>
2+ import type { PackageLikes } from ' #shared/types/social'
23import { useModal } from ' ~/composables/useModal'
34import { useAtproto } from ' ~/composables/atproto/useAtproto'
45import { togglePackageLike } from ' ~/utils/atproto/likes'
@@ -37,17 +38,33 @@ const { user } = useAtproto()
3738const authModal = useModal (' auth-modal' )
3839const 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-
4852const 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
5269const 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