Skip to content
Open
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
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,
}
Comment on lines +113 to +118
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't preserve an explicit null rank here.

PackageLikes.topLikedRank uses null to mean “not in the top 10”, but Line 117 falls back to the previous rank for both undefined and null. If an unlike or follow-up refresh legitimately returns null, the badge and leaderboard link stay visible with stale ranking data until the page reloads. Preserve the old value only when the property is actually absent.

Proposed fix
     likesData.value = result.success
       ? {
           ...previousLikesState,
           ...result.data,
-          topLikedRank: result.data.topLikedRank ?? previousLikesState.topLikedRank,
+          topLikedRank:
+            'topLikedRank' in result.data
+              ? result.data.topLikedRank
+              : previousLikesState.topLikedRank,
         }
       : previousLikesState
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
likesData.value = result.success
? {
...previousLikesState,
...result.data,
topLikedRank: result.data.topLikedRank ?? previousLikesState.topLikedRank,
}
likesData.value = result.success
? {
...previousLikesState,
...result.data,
topLikedRank:
'topLikedRank' in result.data
? result.data.topLikedRank
: previousLikesState.topLikedRank,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/Package/Likes.vue` around lines 113 - 118, The merge is
incorrectly treating null as missing for topLikedRank; change the assignment in
likesData.value so topLikedRank is set to result.data.topLikedRank when the
server explicitly returned null (i.e., preserve null), and only fall back to
previousLikesState.topLikedRank when the property is actually absent — use a
presence check on result.data (e.g., 'topLikedRank' in result.data or
hasOwnProperty) rather than nullish coalescing; update the block that spreads
previousLikesState and result.data (referencing likesData.value,
previousLikesState, result.data, and topLikedRank) accordingly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems pretty unlikely and not really worth accounting for? The intent here was to handle the much more common situation of liking or unliking a package that does have a topLinkedRank, without nulling it out in the response.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@serhalp, that's a fair point — looking at the AI summary for server/utils/atproto/utils/likes.ts, the toggle endpoint explicitly and intentionally always returns topLikedRank: null (it doesn't call the leaderboard API). The ?? fallback is therefore the correct tool: it preserves the rank fetched on initial page load so the badge doesn't vanish during the optimistic like/unlike cycle. The unit test also directly validates this "rank survives a like response that omits/nulls the rank" contract. I was wrong to flag this — the current code is correct as-is.

ʕ •ᴥ•ʔ


✏️ Learnings added
Learnt from: serhalp
Repo: npmx-dev/npmx.dev PR: 2459
File: app/components/Package/Likes.vue:113-118
Timestamp: 2026-04-26T00:01:04.368Z
Learning: In `app/components/Package/Likes.vue` (npmx-dev/npmx.dev), the toggle-like API (`server/utils/atproto/utils/likes.ts`) always returns `topLikedRank: null` because it does not call the leaderboard API. Therefore, the nullish-coalescing fallback `result.data.topLikedRank ?? previousLikesState.topLikedRank` is intentional: it preserves the rank from the initial page-load fetch so the top-rank badge does not disappear during like/unlike actions. Do not flag this as a bug or suggest replacing `??` with a property-presence check.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: jellydeck
Repo: npmx-dev/npmx.dev PR: 904
File: app/components/Package/AccessControls.vue:253-253
Timestamp: 2026-02-04T05:34:20.527Z
Learning: In the npmx.dev project, ensure that focus-visible styling for button and select elements is implemented globally in app/assets/main.css with the rule: button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }. Do not apply per-element inline utility classes like focus-visible:outline-accent/70 on these elements in Vue templates or components (e.g., AccessControls.vue). Rely on the global rule for consistency and maintainability; only use inline focus-visible utilities when styling non-button/select elements or in exceptional cases outside the global scope.

Learnt from: jellydeck
Repo: npmx-dev/npmx.dev PR: 904
File: app/components/Package/Versions.vue:332-332
Timestamp: 2026-02-04T05:34:54.335Z
Learning: In the npmx.dev project, focus-visible styling for buttons and selects is applied globally via main.css with: button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }. Therefore, individual buttons or selects in Vue components should not rely on inline focus-visible utility classes like focus-visible:outline-accent/70. Ensure components do not add per-element focus-visible utilities; rely on the global rule. If a component seems to require a different focus state, adjust only through global CSS or maintain accessibility with native focus styles, rather than inserting inline utility classes.

Learnt from: userquin
Repo: npmx-dev/npmx.dev PR: 1170
File: app/components/Package/MetricsBadges.vue:66-66
Timestamp: 2026-02-08T15:02:02.232Z
Learning: In Vue components that use UnoCSS with the preset-icons collection, prefer colon-syntax for icons (e.g., i-carbon:checkmark) over the dash-separated form (i-carbon-checkmark). This aids UnoCSS in resolving the correct collection directly, which improves performance for long icon names. Apply this pattern to all Vue components (e.g., app/components/**/*.vue) where UnoCSS icons are used; ensure UnoCSS is configured with the preset-icons collection.

Learnt from: userquin
Repo: npmx-dev/npmx.dev PR: 1335
File: app/components/Compare/FacetSelector.vue:72-78
Timestamp: 2026-02-10T15:47:33.467Z
Learning: In the npmx.dev project, ButtonBase (used via app/components/ButtonBase.vue or similar) provides default classes: border border-border. When styling ButtonBase instances in Vue components (e.g., app/components/Compare/FacetSelector.vue and other files under app/components), avoid duplicating the border class to prevent the HTML validator error no-dup-class and CI failures. If styling overrides are needed, ensure only unique classes are applied (remove redundant border classes or adjust via props) so the default border remains intact without duplication.

Learnt from: abbeyperini
Repo: npmx-dev/npmx.dev PR: 1049
File: app/components/Settings/Toggle.client.vue:22-29
Timestamp: 2026-02-11T00:01:33.121Z
Learning: In Vue 3.4 and later, you can use same-name shorthand for attribute bindings: use :attributeName instead of :attributeName="attributeName" when binding to a variable with the same name in scope. For example, :id is equivalent to :id="id" when an id variable exists. Apply this shorthand in .vue components (notably in Settings/Toggle.client.vue) to simplify templates. Ensure the bound variable exists and that you are using a Vue version that supports this shorthand.

Learnt from: alexdln
Repo: npmx-dev/npmx.dev PR: 1845
File: app/components/InstantSearch.vue:6-11
Timestamp: 2026-03-03T09:42:52.533Z
Learning: Maintain the established prehydration pattern across the project: use JSON.parse(localStorage.getItem('npmx-settings') || '{}') without per-call try-catch blocks. Do not introduce try-catch error handling for this pattern unless a coordinated, project-wide refactor of all onPrehydrate readers is planned and executed.

Learnt from: graphieros
Repo: npmx-dev/npmx.dev PR: 1974
File: app/components/Compare/FacetBarChart.vue:174-178
Timestamp: 2026-03-07T12:00:18.120Z
Learning: When reviewing Compare/FacetBarChart.vue (or other Vue components in app/components), treat config.datapoint as a runtime object that can include extra properties from the dataset item (e.g., formattedValue, custom metadata). The TypeScript typings for config.datapoint may be incomplete, but that should not be flagged as a runtime error. Base reviews on actual runtime behavior and data presence, not on missing type checks; focus on correct usage and data-driven effects.

Learnt from: mihaizaurus
Repo: npmx-dev/npmx.dev PR: 2072
File: app/components/Compare/ComparisonGrid.vue:38-40
Timestamp: 2026-03-14T08:57:22.084Z
Learning: In Vue components, when using v-for, provide a stable unique key (e.g., :key="col.name"). This learning notes that in app/components/Compare/ComparisonGrid.vue, using :key="col.name" is safe because duplicates are prevented upstream: app/components/Compare/PackageSelector.vue enforces two guards (filteredResults excludes already-selected names; addPackage early-returns if the name already exists). Do not flag :key="col.name" as a collision risk in this context. If applying this guideline elsewhere, ensure a unique key is guaranteed by logic or consider fallbacks (e.g., index or composite keys) only if duplicates could be introduced.

Learnt from: ShroXd
Repo: npmx-dev/npmx.dev PR: 2025
File: app/components/Package/Versions.vue:533-540
Timestamp: 2026-03-15T03:59:32.483Z
Learning: In Vue 3, templates that use v-if/v-else render exactly one root element at runtime. Attributes (data-testid, title, etc.) will fall through to the active root without needing explicit prop forwarding. Do not flag attribute forwarding as a bug for this pattern; reserve review for cases where multiple roots render simultaneously or where explicit forwarding is required by design. This applies broadly to Vue single-file components using v-if/v-else.

Learnt from: graphieros
Repo: npmx-dev/npmx.dev PR: 2419
File: app/components/Compare/FacetQuadrantChart.vue:387-431
Timestamp: 2026-04-08T13:59:06.437Z
Learning: In the npmx-dev/npmx.dev project, vue-data-ui’s DOM-to-PNG export flow automatically ignores/hides any DOM elements marked with the `data-dom-to-png-ignore` attribute. Therefore, when reviewing Vue templates (including `#svg` slot content like in `app/components/Compare/FacetQuadrantChart.vue`), do not flag the absence of additional `v-if="!svg.isPrintingSvg && !svg.isPrintingImg"` guards solely on the basis that elements are already intended to be excluded from the export via `data-dom-to-png-ignore`.

: 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