Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7573953
fix: show license from correct version (#2662)
gameroman May 2, 2026
1cd4f82
feat: add SolidJS data anomalies to download-anomalies.data.ts (#2661)
gameroman May 2, 2026
1fef8da
feat: show badge on top liked packages, link to leaderboard (#2459)
serhalp May 3, 2026
c26421f
chore: install Vercel Speed Insights (#2671)
vercel[bot] May 3, 2026
5c629a2
chore(deps): update devdependency @e18e/eslint-plugin to v0.4.1 (#2673)
renovate[bot] May 4, 2026
8b54042
chore(deps): lock file maintenance (#2674)
renovate[bot] May 4, 2026
a72bfa9
chore(deps): bump @storybook-vue/nuxt to latest (#2664)
cylewaitforit May 4, 2026
929b2c3
refactor: fix or suppress existing oxlint warnings (#2634)
serhalp May 4, 2026
e11ed69
chore(deps): update dependency nuxt to v4.4.4 (#2659)
renovate[bot] May 4, 2026
50997b7
refactor(ui): use new useClipboard with async (#2675)
MatteoGabriele May 4, 2026
018097e
chore(deps): update dependency @napi-rs/canvas to v1 (#2676)
renovate[bot] May 4, 2026
b8abae0
fix(i18n): add-missing-norwegian-nb-NO-translations (#2677)
bonsak May 5, 2026
b459e1f
fix: also pin dev dependency install version when trust is downgraded…
gameroman May 5, 2026
001d748
chore(deps): update dependency vue to v3.5.34 (#2680)
renovate[bot] May 6, 2026
7726489
fix: resolve injustice (#2686)
sheremet-va May 7, 2026
9c703da
feat: add timeline chart (#2663)
graphieros May 7, 2026
8936f1a
fix: move tooltips to the sides on line charts to free the view (#2688)
graphieros May 8, 2026
8ef7525
fix(i18n): translate missing pt-PT strings for leaderboard and likes …
joaopalmeiro May 8, 2026
8d4e18d
chore: explicitly import node process (#2685)
iiio2 May 8, 2026
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
196 changes: 122 additions & 74 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,33 @@ 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 likeTooltipLabel = 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 +78,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 +101,7 @@ const likeAction = async () => {

// Optimistic update
likesData.value = {
...previousLikesState,
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
userHasLiked: !currentlyLiked,
}
Expand All @@ -87,86 +110,77 @@ 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="likeTooltipLabel" 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="top-liked-badge-anchor"
>
<NuxtLink
:to="{ name: 'leaderboard-likes' }"
:aria-label="topLikedBadgeLabel"
data-testid="top-liked-badge"
class="top-liked-badge"
>
<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 @@ -178,8 +192,42 @@ const likeAction = async () => {
animation: float-up 0.75s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}

.top-liked-badge-anchor {
position: absolute;
inset-inline-end: -0.5rem;
top: -0.4rem;
z-index: 1;
}

.top-liked-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
padding: 0.125rem 0.375rem;
border: 1px solid var(--bg);
border-radius: 9999px;
background: var(--accent);
color: var(--bg);
font-size: 0.6875rem;
font-weight: 700;
line-height: 1;
text-decoration: none;
box-shadow: 0 2px 6px color-mix(in oklab, var(--accent) 14%, transparent);
transition: box-shadow 160ms ease;
}

.top-liked-badge:hover {
box-shadow: 0 4px 10px color-mix(in oklab, var(--accent) 18%, transparent);
}

.top-liked-badge:focus-visible {
outline: 2px solid var(--fg);
outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
.likeFloat {
.like-float {
display: none;
}
}
Expand Down
Loading
Loading