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,34 @@ 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)
4752
4853const isLoadingLikeData = computed (
4954 () => likeStatus .value === ' pending' || likeStatus .value === ' idle' ,
5055)
56+ const isPackageLiked = computed (() => likesData .value ?.userHasLiked ?? false )
57+ const topLikedRank = computed (() => likesData .value ?.topLikedRank ?? null )
58+ const likeButtonLabel = computed (() =>
59+ isPackageLiked .value ? $t (' package.likes.unlike' ) : $t (' package.likes.like' ),
60+ )
61+ const likeTooltipText = computed (() =>
62+ isLoadingLikeData .value ? $t (' common.loading' ) : likeButtonLabel .value ,
63+ )
64+ const topLikedBadgeLabel = computed (() =>
65+ topLikedRank .value == null
66+ ? ' '
67+ : $t (' package.likes.top_rank_link_label' , { rank: topLikedRank .value }),
68+ )
5169
5270const isLikeActionPending = shallowRef (false )
5371
@@ -61,6 +79,11 @@ const likeAction = async () => {
6179
6280 const currentlyLiked = likesData .value ?.userHasLiked ?? false
6381 const currentLikes = likesData .value ?.totalLikes ?? 0
82+ const previousLikesState: PackageLikes = {
83+ totalLikes: currentLikes ,
84+ userHasLiked: currentlyLiked ,
85+ topLikedRank: topLikedRank .value ,
86+ }
6487
6588 likeAnimKey .value ++
6689
@@ -79,6 +102,7 @@ const likeAction = async () => {
79102
80103 // Optimistic update
81104 likesData .value = {
105+ ... previousLikesState ,
82106 totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1 ,
83107 userHasLiked: ! currentlyLiked ,
84108 }
@@ -92,81 +116,81 @@ const likeAction = async () => {
92116
93117 if (result .success ) {
94118 // Update with server response
95- likesData .value = result .data
96- } else {
97- // Revert on error
98119 likesData .value = {
99- totalLikes: currentLikes ,
100- userHasLiked: currentlyLiked ,
120+ ... previousLikesState ,
121+ ... result .data ,
122+ topLikedRank: result .data .topLikedRank ?? previousLikesState .topLikedRank ,
101123 }
124+ } else {
125+ // Revert on error
126+ likesData .value = previousLikesState
102127 }
103128 } catch {
104129 // Revert on error
105- likesData .value = {
106- totalLikes: currentLikes ,
107- userHasLiked: currentlyLiked ,
108- }
130+ likesData .value = previousLikesState
109131 isLikeActionPending .value = false
110132 }
111133}
112134 </script >
113135
114136<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"
137+ <div class =" relative inline-flex items-center" >
138+ <TooltipApp :text =" likeTooltipText" position =" bottom" class =" items-center" strategy =" fixed" >
139+ <div class =" relative inline-flex" >
140+ <span v-if =" showLikeFloat" :key =" likeFloatKey" aria-hidden =" true" class =" like-float"
141+ >+1</span
142+ >
143+ <ButtonBase
144+ @click =" likeAction"
145+ size =" md"
146+ :aria-label =" likeButtonLabel"
147+ :aria-pressed =" isPackageLiked"
148+ >
149+ <span
150+ :key =" likeAnimKey"
151+ :class ="
152+ isPackageLiked
153+ ? 'i-lucide:heart-minus fill-red-500 text-red-500'
154+ : 'i-lucide:heart-plus'
155+ "
156+ :style =" heartAnimStyle"
157+ aria-hidden =" true"
158+ class =" inline-block w-4 h-4"
159+ />
160+ <span
161+ v-if =" isLoadingLikeData"
162+ class =" i-svg-spinners:ring-resize w-3 h-3 my-0.5"
163+ aria-hidden =" true"
164+ />
165+ <span v-else >
166+ {{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}
167+ </span >
168+ </ButtonBase >
169+ </div >
170+ </TooltipApp >
171+
172+ <TooltipApp
173+ v-if =" topLikedRank != null"
174+ :text =" $t('package.likes.top_rank_tooltip', { rank: topLikedRank })"
175+ position =" left"
176+ :offset =" 8"
177+ strategy =" fixed"
178+ class =" absolute [inset-inline-end:-0.5rem] top-[-0.4rem] z-1"
179+ >
180+ <NuxtLink
181+ :to =" { name: 'leaderboard-likes' }"
182+ :aria-label =" topLikedBadgeLabel"
183+ data-testid =" top-liked-badge"
184+ class =" inline-flex items-center justify-center min-w-5 rounded-full px-1.5 py-0.5 text-2xs font-bold leading-none no-underline text-[var(--bg)] border border-[var(--bg)] bg-[radial-gradient(circle_at_28%_25%,rgb(255_255_255_/_0.34),transparent_38%),linear-gradient(135deg,color-mix(in_oklab,white_10%,var(--accent))_0%,var(--accent)_100%)] shadow-[0_1px_0_rgb(255_255_255_/_0.32)_inset,0_2px_6px_color-mix(in_oklab,var(--accent)_14%,transparent)] transition-shadow duration-[160ms] hover:shadow-[0_1px_0_rgb(255_255_255_/_0.38)_inset,0_4px_10px_color-mix(in_oklab,var(--accent)_18%,transparent)] focus-visible:outline-2 focus-visible:outline-fg focus-visible:outline-offset-2"
138185 >
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 >
186+ <span >{{ $t('package.likes.top_rank_label', { rank: topLikedRank }) }}</span >
187+ </NuxtLink >
188+ </TooltipApp >
189+ </div >
161190</template >
162191
163- <style module>
164- .likeWrapper {
165- position : relative ;
166- display : inline-flex ;
167- }
168-
169- .likeFloat {
192+ <style scoped>
193+ .like-float {
170194 position : absolute ;
171195 top : 0 ;
172196 left : 50% ;
0 commit comments