Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 90 additions & 73 deletions app/components/Package/Likes.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { PackageLikes } from '#shared/types/social'
import { useModal } from '~/composables/useModal'
import { useAtproto } from '~/composables/atproto/useAtproto'
import { togglePackageLike } from '~/utils/atproto/likes'
Expand Down Expand Up @@ -37,17 +38,34 @@ const { user } = useAtproto()
const authModal = useModal('auth-modal')
const compactNumberFormatter = useCompactNumberFormatter()

const { data: likesData, status: likeStatus } = useFetch(
const { data: likesData, status: likeStatus } = useFetch<PackageLikes>(
() => `/api/social/likes/${props.packageName}`,
{
default: () => ({ totalLikes: 0, userHasLiked: false }),
default: () => ({
totalLikes: 0,
userHasLiked: false,
topLikedRank: null,
}),
server: false,
},
)

const isLoadingLikeData = computed(
() => likeStatus.value === 'pending' || likeStatus.value === 'idle',
)
const isPackageLiked = computed(() => likesData.value?.userHasLiked ?? false)
const topLikedRank = computed(() => likesData.value?.topLikedRank ?? null)
const likeButtonLabel = computed(() =>
isPackageLiked.value ? $t('package.likes.unlike') : $t('package.likes.like'),
)
const likeTooltipText = computed(() =>
isLoadingLikeData.value ? $t('common.loading') : likeButtonLabel.value,
)
const topLikedBadgeLabel = computed(() =>
topLikedRank.value == null
? ''
: $t('package.likes.top_rank_link_label', { rank: topLikedRank.value }),
)

const isLikeActionPending = shallowRef(false)

Expand All @@ -61,6 +79,11 @@ const likeAction = async () => {

const currentlyLiked = likesData.value?.userHasLiked ?? false
const currentLikes = likesData.value?.totalLikes ?? 0
const previousLikesState: PackageLikes = {
totalLikes: currentLikes,
userHasLiked: currentlyLiked,
topLikedRank: topLikedRank.value,
}

likeAnimKey.value++

Expand All @@ -79,6 +102,7 @@ const likeAction = async () => {

// Optimistic update
likesData.value = {
...previousLikesState,
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
userHasLiked: !currentlyLiked,
}
Expand All @@ -87,86 +111,79 @@ const likeAction = async () => {

try {
const result = await togglePackageLike(props.packageName, currentlyLiked, user.value?.handle)

isLikeActionPending.value = false

if (result.success) {
// Update with server response
likesData.value = result.data
} else {
// Revert on error
likesData.value = {
totalLikes: currentLikes,
userHasLiked: currentlyLiked,
}
}
likesData.value = result.success
? {
...previousLikesState,
...result.data,
topLikedRank: result.data.topLikedRank ?? previousLikesState.topLikedRank,
}
: previousLikesState
} catch {
// Revert on error
likesData.value = {
totalLikes: currentLikes,
userHasLiked: currentlyLiked,
}
likesData.value = previousLikesState
} finally {
isLikeActionPending.value = false
}
}
</script>

<template>
<TooltipApp
:text="
isLoadingLikeData
? $t('common.loading')
: likesData?.userHasLiked
? $t('package.likes.unlike')
: $t('package.likes.like')
"
position="bottom"
class="items-center"
strategy="fixed"
>
<div :class="$style.likeWrapper">
<span v-if="showLikeFloat" :key="likeFloatKey" aria-hidden="true" :class="$style.likeFloat"
>+1</span
>
<ButtonBase
@click="likeAction"
size="md"
:aria-label="
likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like')
"
:aria-pressed="likesData?.userHasLiked"
<div class="relative inline-flex items-center">
<TooltipApp :text="likeTooltipText" position="bottom" class="items-center" strategy="fixed">
<div class="relative inline-flex">
<span v-if="showLikeFloat" :key="likeFloatKey" aria-hidden="true" class="like-float"
>+1</span
>
<ButtonBase
@click="likeAction"
size="md"
:aria-label="likeButtonLabel"
:aria-pressed="isPackageLiked"
>
<span
:key="likeAnimKey"
:class="
isPackageLiked
? 'i-lucide:heart-minus fill-red-500 text-red-500'
: 'i-lucide:heart-plus'
"
:style="heartAnimStyle"
aria-hidden="true"
class="inline-block w-4 h-4"
/>
<span
v-if="isLoadingLikeData"
class="i-svg-spinners:ring-resize w-3 h-3 my-0.5"
aria-hidden="true"
/>
<span v-else>
{{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}
</span>
</ButtonBase>
</div>
</TooltipApp>

<TooltipApp
v-if="topLikedRank != null"
:text="$t('package.likes.top_rank_tooltip', { rank: topLikedRank })"
position="left"
:offset="8"
strategy="fixed"
class="absolute [inset-inline-end:-0.5rem] top-[-0.4rem] z-1"
>
<NuxtLink
:to="{ name: 'leaderboard-likes' }"
:aria-label="topLikedBadgeLabel"
data-testid="top-liked-badge"
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"
>
<span
:key="likeAnimKey"
:class="
likesData?.userHasLiked
? 'i-lucide:heart-minus fill-red-500 text-red-500'
: 'i-lucide:heart-plus'
"
:style="heartAnimStyle"
aria-hidden="true"
class="inline-block w-4 h-4"
/>
<span
v-if="isLoadingLikeData"
class="i-svg-spinners:ring-resize w-3 h-3 my-0.5"
aria-hidden="true"
/>
<span v-else>
{{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}
</span>
</ButtonBase>
</div>
</TooltipApp>
<span>{{ $t('package.likes.top_rank_label', { rank: topLikedRank }) }}</span>
</NuxtLink>
</TooltipApp>
</div>
</template>

<style module>
.likeWrapper {
position: relative;
display: inline-flex;
}

.likeFloat {
<style scoped>
.like-float {
position: absolute;
top: 0;
left: 50%;
Expand All @@ -179,7 +196,7 @@ const likeAction = async () => {
}

@media (prefers-reduced-motion: reduce) {
.likeFloat {
.like-float {
display: none;
}
}
Expand Down
91 changes: 91 additions & 0 deletions app/pages/leaderboard/likes.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<script setup lang="ts">
import type { LikesLeaderboardEntry } from '#shared/types/social'

useSeoMeta({
title: () => `${$t('leaderboard.likes.title')} - npmx`,
ogTitle: () => `${$t('leaderboard.likes.title')} - npmx`,
twitterTitle: () => `${$t('leaderboard.likes.title')} - npmx`,
description: () => $t('leaderboard.likes.description'),
ogDescription: () => $t('leaderboard.likes.description'),
twitterDescription: () => $t('leaderboard.likes.description'),
})

const compactNumberFormatter = useCompactNumberFormatter()

const { data: leaderboardEntries } = await useFetch<LikesLeaderboardEntry[]>(
'/api/leaderboard/likes',
{
default: () => [],
},
)
</script>

<template>
<main class="container flex-1 py-12 sm:py-16 overflow-x-hidden">
<article class="max-w-3xl mx-auto">
<header class="mb-10">
<div class="flex items-baseline justify-between gap-4 mb-4">
<h1 class="font-mono text-3xl sm:text-4xl font-medium">
{{ $t('leaderboard.likes.title') }}
</h1>
<BackButton />
</div>
<p class="text-fg-muted text-lg">
{{ $t('leaderboard.likes.description') }}
</p>
</header>

<BaseCard
v-if="leaderboardEntries.length === 0"
class="cursor-default hover:(border-border bg-bg-subtle)"
>
<h2 class="font-mono text-lg mb-2">
{{ $t('leaderboard.likes.unavailable_title') }}
</h2>
<p class="text-fg-muted">
{{ $t('leaderboard.likes.unavailable_description') }}
</p>
</BaseCard>

<ol v-else class="space-y-4 list-none m-0 p-0">
<li v-for="entry in leaderboardEntries" :key="entry.subjectRef">
<NuxtLink
:to="packageRoute(entry.packageName)"
class="block no-underline hover:no-underline"
>
<BaseCard class="flex items-center justify-between gap-4 min-w-0">
<div class="flex items-center gap-4 min-w-0">
<div
aria-hidden="true"
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"
>
#{{ entry.rank }}
</div>
<div class="min-w-0">
<p class="text-xs uppercase tracking-wider text-fg-muted mb-1">
{{ $t('leaderboard.likes.rank') }} {{ entry.rank }}
</p>
<p class="font-mono text-lg truncate" :title="entry.packageName">
{{ entry.packageName }}
</p>
</div>
</div>

<div class="flex items-center gap-3 shrink-0">
<div class="text-end">
<p class="text-xs uppercase tracking-wider text-fg-muted mb-1">
{{ $t('leaderboard.likes.likes') }}
</p>
<p class="font-mono text-lg">
{{ compactNumberFormatter.format(entry.totalLikes) }}
</p>
</div>
<span aria-hidden="true" class="text-fg-muted">↗</span>
</div>
</BaseCard>
</NuxtLink>
</li>
</ol>
</article>
</main>
</template>
15 changes: 14 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,10 @@
},
"likes": {
"like": "Like this package",
"unlike": "Unlike this package"
"unlike": "Unlike this package",
"top_rank_tooltip": "This is in the top 10 most liked packages on npmx! (#{rank})",
"top_rank_label": "#{rank}",
"top_rank_link_label": "View likes leaderboard. This package is ranked #{rank}."
},
"docs": {
"contents": "Contents",
Expand Down Expand Up @@ -761,6 +764,16 @@
"tarball": "Download Tarball as .tar.gz"
}
},
"leaderboard": {
"likes": {
"title": "Likes Leaderboard",
"description": "The 10 most liked packages on npmx right now.",
"rank": "Rank",
"likes": "Likes",
"unavailable_title": "No likes leaderboard yet",
"unavailable_description": "We don't have a likes leaderboard to show right now."
}
},
"connector": {
"modal": {
"title": "Local Connector",
Expand Down
15 changes: 14 additions & 1 deletion i18n/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,10 @@
},
"likes": {
"like": "Liker ce paquet",
"unlike": "Retirer le like"
"unlike": "Retirer le like",
"top_rank_tooltip": "Ce paquet figure parmi les 10 ayant le plus de likes sur npmx (n°{rank})",
"top_rank_label": "n°{rank}",
"top_rank_link_label": "Voir le classement des likes. Ce paquet est classé n°{rank}."
},
"docs": {
"contents": "Sommaire",
Expand Down Expand Up @@ -759,6 +762,16 @@
"tarball": "Télécharger le tarball au format .tar.gz"
}
},
"leaderboard": {
"likes": {
"title": "Classement des likes",
"description": "Les 10 paquets les plus likés sur npmx en ce moment.",
"rank": "Rang",
"likes": "Likes",
"unavailable_title": "Pas encore de classement des likes",
"unavailable_description": "Nous n'avons pas encore de classement des likes à afficher."
}
},
"connector": {
"modal": {
"title": "Connecteur local",
Expand Down
Loading
Loading