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
15 changes: 14 additions & 1 deletion app/components/Package/Likes.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ const topLikedBadgeLabel = computed(() =>

const isLikeActionPending = shallowRef(false)

const isCountTruncated = computed(() => (likesData.value?.totalLikes ?? 0) >= 1000)

const likeAction = async () => {
if (user.value?.handle == null) {
authModal.open()
Expand Down Expand Up @@ -154,7 +156,18 @@ const likeAction = async () => {
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>
<span v-else class="inline-flex items-center gap-0.5">
{{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}
<span
v-if="isCountTruncated"
class="text-fg-subtle text-3xs leading-none"
:aria-label="
$t('package.likes.count_truncated', { count: likesData?.totalLikes ?? 0 })
"
>
+
</span>
</span>
</ButtonBase>
</div>
</TooltipApp>
Expand Down
13 changes: 0 additions & 13 deletions app/composables/atproto/useProfileLikes.ts

This file was deleted.

105 changes: 97 additions & 8 deletions app/pages/profile/[identity]/index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { updateProfile as updateProfileUtil } from '~/utils/atproto/profile'
import { fetchProfileLikes } from '~/utils/atproto/likes'
import type { CommandPaletteContextCommandInput } from '~/types/command-palette'
import { getSafeHttpUrl } from '#shared/utils/url'

Expand Down Expand Up @@ -79,13 +80,88 @@ async function updateProfile() {
}
}

const { data: likes, status } = useProfileLikes(identity)
const allLikesRecords = ref<Array<{ value: { subjectRef: string } }>>([])
const likesCursor = shallowRef<string | null>(null)
const likesLoadingMore = shallowRef(false)
const likesError = shallowRef(false)
const likesLoaded = shallowRef(false)

async function loadInitialLikes() {
try {
const result = await fetchProfileLikes(identity.value, null, 20)
allLikesRecords.value = result.records
likesCursor.value = result.cursor
likesError.value = false
likesLoaded.value = true
} catch {
likesError.value = true
likesLoaded.value = true
}
}

async function loadMoreLikes() {
if (likesLoadingMore.value || !likesCursor.value) return
likesLoadingMore.value = true
try {
const result = await fetchProfileLikes(identity.value, likesCursor.value, 20)
allLikesRecords.value = [...allLikesRecords.value, ...result.records]
likesCursor.value = result.cursor
} catch {
likesError.value = true
} finally {
likesLoadingMore.value = false
}
}

const hasMoreLikes = computed(() => likesCursor.value !== null)
const isLoadingInitialLikes = computed(
() => allLikesRecords.value.length === 0 && !likesError.value && !likesLoaded.value,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

onMounted(() => {
loadInitialLikes()
})

let observer: IntersectionObserver | null = null

onUnmounted(() => {
if (observer) {
observer.disconnect()
observer = null
}
})

function setupInfiniteScroll() {
if (observer) {
observer.disconnect()
}
observer = new IntersectionObserver(
entries => {
const target = entries[0]
if (target?.isIntersecting && hasMoreLikes.value && !likesLoadingMore.value) {
loadMoreLikes()
}
},
{ rootMargin: '200px' },
)

nextTick(() => {
const sentinel = document.getElementById('likes-scroll-sentinel')
if (sentinel && observer) {
observer.observe(sentinel)
}
})
}

watch(allLikesRecords, () => {
setupInfiniteScroll()
})

const showInviteSection = computed(() => {
return (
profile.value.recordExists === false &&
status.value === 'success' &&
!likes.value?.records?.length &&
!likesError.value &&
allLikesRecords.value.length === 0 &&
!userPending.value &&
user.value?.handle !== profile.value.handle
)
Expand Down Expand Up @@ -239,18 +315,31 @@ defineOgImage(
dir="ltr"
>
{{ $t('profile.likes') }}
<span v-if="likes">({{ likes.records?.length ?? 0 }})</span>
<span>({{ allLikesRecords.length ?? 0 }})</span>
</h2>
<div v-if="status === 'pending'" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div v-if="isLoadingInitialLikes" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<SkeletonBlock v-for="i in 4" :key="i" class="h-16 rounded-lg" />
</div>
<div v-else-if="status === 'error'">
<div v-else-if="likesError">
<p>{{ $t('common.error') }}</p>
</div>
<div v-else-if="likes?.records" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<PackageLikeCard v-for="like in likes.records" :packageUrl="like.value.subjectRef" />
<div v-else-if="allLikesRecords.length > 0" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<PackageLikeCard
v-for="like in allLikesRecords"
:key="like.value.subjectRef"
:packageUrl="like.value.subjectRef"
/>
</div>

<!-- Loading more indicator for infinite scroll -->
<div v-if="likesLoadingMore" class="flex items-center justify-center py-4 gap-2">
<span class="i-svg-spinners:ring-resize w-4 h-4" aria-hidden="true" />
<span class="text-fg-muted text-sm">{{ $t('common.loading') }}</span>
</div>

<!-- Scroll sentinel for intersection observer -->
<div id="likes-scroll-sentinel" class="h-1" />

<!-- Invite section: shown when user does not have npmx profile or any like lexicons -->
<div
v-if="showInviteSection"
Expand Down
36 changes: 36 additions & 0 deletions app/utils/atproto/likes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import { FetchError } from 'ofetch'
import { handleAuthError } from '~/utils/atproto/helpers'
import type { PackageLikes } from '#shared/types/social'

export type PaginatedProfileLikes = {
records: {
value: {
subjectRef: string
}
}[]
cursor: string | null
hasNextPage: boolean
}

type LikeResult = { success: true; data: PackageLikes } | { success: false; error: Error }

/**
Expand Down Expand Up @@ -52,3 +62,29 @@ export async function togglePackageLike(
? unlikePackage(packageName, userHandle)
: likePackage(packageName, userHandle)
}

/**
* Fetches paginated profile likes for a given handle.
*/
export async function fetchProfileLikes(
handle: string,
cursor?: string | null,
limit = 20,
): Promise<PaginatedProfileLikes> {
const params = new URLSearchParams({ limit: String(limit) })
if (cursor) {
params.set('cursor', cursor)
}

try {
const result = await $fetch<PaginatedProfileLikes>(
`/api/social/profile/${handle}/likes?${params.toString()}`,
)
return result
} catch (e) {
if (e instanceof FetchError) {
await handleAuthError(e, undefined)
}
return { records: [], cursor: null, hasNextPage: false }
}
}
10 changes: 7 additions & 3 deletions i18n/locales/bn-IN.json
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@
"more_replies": "{count} আরো উত্তর... | {count} আরো উত্তর..."
}
},
"noodles": {
"missing": {}
},
"settings": {
"title": "সেটিংস",
"tagline": "আপনার npmx অভিজ্ঞতা কাস্টমাইজ করুন",
Expand Down Expand Up @@ -455,8 +458,7 @@
"like": "এই প্যাকেজটি পছন্দ করুন",
"unlike": "এই প্যাকেজটি আর পছন্দ নয়",
"top_rank_tooltip": "এটি npmx-এ শীর্ষ 10টি সর্বাধিক পছন্দ করা প্যাকেজের মধ্যে রয়েছে! (#{rank})",
"top_rank_label": "#{rank}",
"top_rank_link_label": "পছন্দ লিডারবোর্ড দেখুন. এই প্যাকেজটি #{rank} র‌্যাঙ্ক করা হয়েছে."
"top_rank_label": "#{rank}"
},
"docs": {
"contents": "বিষয়বস্তু",
Expand Down Expand Up @@ -708,7 +710,8 @@
"general_description": "Y অক্ষ ডাউনলোডের সংখ্যা নির্দেশ করে। X অক্ষ {start_date} থেকে {end_date} পর্যন্ত তারিখের পরিসর নির্দেশ করে, যেখানে সময়কাল হলো {granularity}। {estimation_notice} {packages_analysis}। {watermark}।",
"facet_bar_general_description": "{packages} এর জন্য অনুভূমিক বার চার্ট, যেখানে {facet} ({description}) তুলনা করা হয়েছে। {facet_analysis} {watermark}।",
"facet_bar_analysis": "{package_name} এর মান {value}।"
}
},
"embedding": {}
},
"downloads": {
"title": "সাপ্তাহিক ডাউনলোড",
Expand Down Expand Up @@ -1390,6 +1393,7 @@
"label": "GitHub স্টার",
"description": "GitHub রিপোজিটরিতে স্টারের সংখ্যা"
},
"githubForks": {},
"githubIssues": {
"label": "GitHub ইস্যু",
"description": "GitHub রিপোজিটরিতে ইস্যুর সংখ্যা"
Expand Down
10 changes: 7 additions & 3 deletions i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@
"more_replies": "noch {count} Antwort… | noch {count} Antworten…"
}
},
"noodles": {
"missing": {}
},
"settings": {
"title": "einstellungen",
"tagline": "Passe npmx an deine Vorlieben an",
Expand Down Expand Up @@ -455,8 +458,7 @@
"like": "Dieses Paket liken",
"unlike": "Like entfernen",
"top_rank_tooltip": "Das gehört zu den Top 10 der beliebtesten Pakete auf npmx! (#{rank})",
"top_rank_label": "#{rank}",
"top_rank_link_label": "Rangliste der Likes anzeigen. Dieses Paket steht auf Platz #{rank}."
"top_rank_label": "#{rank}"
},
"docs": {
"contents": "inhalt",
Expand Down Expand Up @@ -709,7 +711,8 @@
"general_description": "Die Y-Achse stellt die Anzahl der Downloads dar. Die X-Achse stellt den Datumsbereich dar, von {start_date} bis {end_date}, mit einem {granularity}en Zeitraum.{estimation_notice} {packages_analysis}. {watermark}.",
"facet_bar_general_description": "Horizontales Balkendiagramm für: {packages}, Vergleich von {facet} ({description}). {facet_analysis} {watermark}.",
"facet_bar_analysis": "{package_name} hat einen Wert von {value}."
}
},
"embedding": {}
},
"downloads": {
"title": "Wöchentliche Downloads",
Expand Down Expand Up @@ -1395,6 +1398,7 @@
"label": "GitHub-Sterne",
"description": "Anzahl der Sterne im GitHub-Repository"
},
"githubForks": {},
"githubIssues": {
"label": "GitHub-Issues",
"description": "Anzahl der Issues im GitHub-Repository"
Expand Down
2 changes: 1 addition & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@
"unlike": "Unlike this package",
"top_rank_tooltip": "This is among 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}."
"count_truncated": "approximately {count} likes"
},
"docs": {
"contents": "Contents",
Expand Down
10 changes: 7 additions & 3 deletions i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@
"more_replies": "{count} respuesta más... | {count} respuestas más..."
}
},
"noodles": {
"missing": {}
},
"settings": {
"title": "configuración",
"tagline": "personaliza tu experiencia en npmx",
Expand Down Expand Up @@ -455,8 +458,7 @@
"like": "Me gusta este paquete",
"unlike": "Ya no me gusta este paquete",
"top_rank_tooltip": "¡Este está entre los 10 paquetes con más me gusta en npmx! (#{rank})",
"top_rank_label": "#{rank}",
"top_rank_link_label": "Ver clasificación de 'me gusta'. Este paquete está en el puesto #{rank}."
"top_rank_label": "#{rank}"
},
"docs": {
"contents": "Contenido",
Expand Down Expand Up @@ -708,7 +710,8 @@
"general_description": "El eje Y representa el número de descargas. El eje X representa el rango de fechas, desde {start_date} hasta {end_date}, con un período de tiempo {granularity}.{estimation_notice} {packages_analysis}. {watermark}.",
"facet_bar_general_description": "Gráfico de barras horizontal para: {packages}, comparando {facet} ({description}). {facet_analysis} {watermark}.",
"facet_bar_analysis": "{package_name} tiene un valor de {value}."
}
},
"embedding": {}
},
"downloads": {
"title": "Descargas Semanales",
Expand Down Expand Up @@ -1390,6 +1393,7 @@
"label": "Estrellas de GitHub",
"description": "Número de estrellas en el repositorio de GitHub"
},
"githubForks": {},
"githubIssues": {
"label": "Incidencias de GitHub",
"description": "Número de incidencias en el repositorio de GitHub"
Expand Down
7 changes: 5 additions & 2 deletions i18n/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,9 @@
"more_replies": "{count} réponse supplémentaire... | {count} réponses supplémentaires..."
}
},
"noodles": {
"missing": {}
},
"settings": {
"title": "paramètres",
"tagline": "personnalisez votre expérience npmx",
Expand Down Expand Up @@ -444,8 +447,7 @@
"like": "Liker ce paquet",
"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}."
"top_rank_label": "n°{rank}"
},
"docs": {
"contents": "Sommaire",
Expand Down Expand Up @@ -1382,6 +1384,7 @@
"label": "Étoiles GitHub",
"description": "Nombre d’étoiles du dépôt GitHub"
},
"githubForks": {},
"githubIssues": {
"label": "Issues GitHub",
"description": "Nombre d’issues ouvertes sur GitHub"
Expand Down
Loading
Loading