Skip to content

Commit 2163460

Browse files
committed
feat: show badge on most liked packages, link to new leaderboard page
Show a small rank badge next to the likes counter/button when a package is in the top 10 most-liked packages, and link that badge to a new in-app likes leaderboard page. For now at least, this is the only way to reach the leaderboard page. Both are powered by server-side fetching of the likes leaderboard API (https://tangled.org/baileytownsend.dev/npmx-likes-leaderboard), maintained by Bailey. Fetches degrade gracefully: no badge is shown on the package page, and the leaderboard page shows a message indicating that the data is unavailable. Successful fetches are cached for 1 hour, and are only revalidated in the background, following a stale-while-revalidate pattern (this is existing behaviour from `server/plugins/fetch-cache`). The leaderboard page is itself cached with ISR, with a revalidation time of 15 minutes.
1 parent eb2e6bb commit 2163460

File tree

17 files changed

+713
-71
lines changed

17 files changed

+713
-71
lines changed

app/components/Package/Likes.vue

Lines changed: 87 additions & 63 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,34 @@ 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
)
4752
4853
const 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
5270
const 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%;

app/pages/leaderboard/likes.vue

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<script setup lang="ts">
2+
import type { LikesLeaderboardEntry } from '#shared/types/social'
3+
4+
useSeoMeta({
5+
title: () => `${$t('leaderboard.likes.title')} - npmx`,
6+
ogTitle: () => `${$t('leaderboard.likes.title')} - npmx`,
7+
twitterTitle: () => `${$t('leaderboard.likes.title')} - npmx`,
8+
description: () => $t('leaderboard.likes.description'),
9+
ogDescription: () => $t('leaderboard.likes.description'),
10+
twitterDescription: () => $t('leaderboard.likes.description'),
11+
})
12+
13+
const compactNumberFormatter = useCompactNumberFormatter()
14+
15+
const { data: leaderboardEntries } = await useFetch<LikesLeaderboardEntry[]>(
16+
'/api/leaderboard/likes',
17+
{
18+
default: () => [],
19+
},
20+
)
21+
</script>
22+
23+
<template>
24+
<main class="container flex-1 py-12 sm:py-16 overflow-x-hidden">
25+
<article class="max-w-3xl mx-auto">
26+
<header class="mb-10">
27+
<div class="flex items-baseline justify-between gap-4 mb-4">
28+
<h1 class="font-mono text-3xl sm:text-4xl font-medium">
29+
{{ $t('leaderboard.likes.title') }}
30+
</h1>
31+
<BackButton />
32+
</div>
33+
<p class="text-fg-muted text-lg">
34+
{{ $t('leaderboard.likes.description') }}
35+
</p>
36+
</header>
37+
38+
<BaseCard
39+
v-if="leaderboardEntries.length === 0"
40+
class="cursor-default hover:(border-border bg-bg-subtle)"
41+
>
42+
<h2 class="font-mono text-lg mb-2">
43+
{{ $t('leaderboard.likes.unavailable_title') }}
44+
</h2>
45+
<p class="text-fg-muted">
46+
{{ $t('leaderboard.likes.unavailable_description') }}
47+
</p>
48+
</BaseCard>
49+
50+
<ol v-else class="space-y-4 list-none m-0 p-0">
51+
<li v-for="entry in leaderboardEntries" :key="entry.subjectRef">
52+
<NuxtLink
53+
:to="packageRoute(entry.packageName)"
54+
class="block no-underline hover:no-underline"
55+
>
56+
<BaseCard class="flex items-center justify-between gap-4 min-w-0">
57+
<div class="flex items-center gap-4 min-w-0">
58+
<div
59+
aria-hidden="true"
60+
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-amber-500/12 text-amber-700 font-mono text-sm dark:text-amber-300"
61+
>
62+
#{{ entry.rank }}
63+
</div>
64+
<div class="min-w-0">
65+
<p class="text-xs uppercase tracking-wider text-fg-muted mb-1">
66+
{{ $t('leaderboard.likes.rank') }} {{ entry.rank }}
67+
</p>
68+
<p class="font-mono text-lg truncate" :title="entry.packageName">
69+
{{ entry.packageName }}
70+
</p>
71+
</div>
72+
</div>
73+
74+
<div class="flex items-center gap-3 shrink-0">
75+
<div class="text-end">
76+
<p class="text-xs uppercase tracking-wider text-fg-muted mb-1">
77+
{{ $t('leaderboard.likes.likes') }}
78+
</p>
79+
<p class="font-mono text-lg">
80+
{{ compactNumberFormatter.format(entry.totalLikes) }}
81+
</p>
82+
</div>
83+
<span aria-hidden="true" class="text-fg-muted">↗</span>
84+
</div>
85+
</BaseCard>
86+
</NuxtLink>
87+
</li>
88+
</ol>
89+
</article>
90+
</main>
91+
</template>

i18n/locales/en.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,10 @@
439439
},
440440
"likes": {
441441
"like": "Like this package",
442-
"unlike": "Unlike this package"
442+
"unlike": "Unlike this package",
443+
"top_rank_tooltip": "This is in the top 10 most liked packages on npmx! (#{rank})",
444+
"top_rank_label": "#{rank}",
445+
"top_rank_link_label": "View likes leaderboard. This package is ranked #{rank}."
443446
},
444447
"docs": {
445448
"contents": "Contents",
@@ -761,6 +764,16 @@
761764
"tarball": "Download Tarball as .tar.gz"
762765
}
763766
},
767+
"leaderboard": {
768+
"likes": {
769+
"title": "Likes Leaderboard",
770+
"description": "The 10 most liked packages on npmx right now.",
771+
"rank": "Rank",
772+
"likes": "Likes",
773+
"unavailable_title": "No likes leaderboard yet",
774+
"unavailable_description": "We don't have a likes leaderboard to show right now."
775+
}
776+
},
764777
"connector": {
765778
"modal": {
766779
"title": "Local Connector",

i18n/locales/fr-FR.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,10 @@
437437
},
438438
"likes": {
439439
"like": "Liker ce paquet",
440-
"unlike": "Retirer le like"
440+
"unlike": "Retirer le like",
441+
"top_rank_tooltip": "Ce paquet figure parmi les 10 ayant le plus de likes sur npmx (n°{rank})",
442+
"top_rank_label": "n°{rank}",
443+
"top_rank_link_label": "Voir le classement des likes. Ce paquet est classé n°{rank}."
441444
},
442445
"docs": {
443446
"contents": "Sommaire",
@@ -759,6 +762,16 @@
759762
"tarball": "Télécharger le tarball au format .tar.gz"
760763
}
761764
},
765+
"leaderboard": {
766+
"likes": {
767+
"title": "Classement des likes",
768+
"description": "Les 10 paquets les plus likés sur npmx en ce moment.",
769+
"rank": "Rang",
770+
"likes": "Likes",
771+
"unavailable_title": "Pas encore de classement des likes",
772+
"unavailable_description": "Nous n'avons pas encore de classement des likes à afficher."
773+
}
774+
},
762775
"connector": {
763776
"modal": {
764777
"title": "Connecteur local",

0 commit comments

Comments
 (0)