diff --git a/app/components/DateTime.vue b/app/components/DateTime.vue index e2fc331bb3..5d604608f9 100644 --- a/app/components/DateTime.vue +++ b/app/components/DateTime.vue @@ -15,6 +15,8 @@ const props = withDefaults( title?: string /** Date style for absolute display */ dateStyle?: 'full' | 'long' | 'medium' | 'short' + /** Time style for absolute display */ + timeStyle?: 'full' | 'long' | 'medium' | 'short' /** Individual date parts for absolute display (alternative to dateStyle) */ year?: 'numeric' | '2-digit' month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow' @@ -23,6 +25,7 @@ const props = withDefaults( { title: undefined, dateStyle: undefined, + timeStyle: undefined, year: undefined, month: undefined, day: undefined, @@ -65,6 +68,7 @@ const titleValue = computed(() => { :datetime="datetime" :title="titleValue" :date-style="dateStyle" + :time-style="timeStyle" :year="year" :month="month" :day="day" @@ -75,6 +79,7 @@ const titleValue = computed(() => { :datetime="datetime" :title="titleValue" :date-style="dateStyle" + :time-style="timeStyle" :year="year" :month="month" :day="day" diff --git a/app/components/Landing/RecentlyLiked.vue b/app/components/Landing/RecentlyLiked.vue new file mode 100644 index 0000000000..4bfc2e8fc9 --- /dev/null +++ b/app/components/Landing/RecentlyLiked.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/app/composables/atproto/useRecentPackageLikes.ts b/app/composables/atproto/useRecentPackageLikes.ts new file mode 100644 index 0000000000..d72c5ecb9f --- /dev/null +++ b/app/composables/atproto/useRecentPackageLikes.ts @@ -0,0 +1,188 @@ +import type { RecentPackageLike } from '~/utils/recent-package-likes' +import { useWebSocket } from '@vueuse/core' +import * as v from 'valibot' +import { encodePackageName } from '#shared/utils/npm' +import { + RECENT_PACKAGE_LIKES_LIMIT, + createPackageLikesSeedUrl, + createPackageLikesStreamUrl, + parseSpacedustPackageLikeEvent, + parseUfosPackageLikeRecords, +} from '~/utils/recent-package-likes' + +const PackageMetaResponseSchema = v.object({ + description: v.optional(v.string()), + weeklyDownloads: v.optional(v.nullable(v.number())), + repositoryStars: v.optional(v.nullable(v.number())), +}) + +const INITIAL_PACKAGE_LIKE_STAGGER_MS = 480 + +type RecentPackageLikeMetadata = Pick< + RecentPackageLike, + 'packageDescription' | 'weeklyDownloads' | 'repositoryStars' +> + +export function useRecentPackageLikes(limit: number = RECENT_PACKAGE_LIKES_LIMIT) { + const likes = shallowRef([]) + const isLoadingInitialLikes = shallowRef(true) + const metadataByPackageName = new Map() + const metadataRequests = new Set() + const initialLikeTimers = new Set>() + + function clearInitialLikeTimers() { + for (const timer of initialLikeTimers) { + clearTimeout(timer) + } + initialLikeTimers.clear() + } + + function addLikes(newLikes: RecentPackageLike[]) { + if (newLikes.length === 0) return + + const enrichedLikes = newLikes.map(like => { + const metadata = metadataByPackageName.get(like.packageName) + return metadata ? { ...like, ...metadata } : like + }) + + const seen = new Set() + likes.value = [...enrichedLikes, ...likes.value] + .filter(like => { + if (seen.has(like.id)) return false + seen.add(like.id) + return true + }) + .sort((a, b) => b.likedAt - a.likedAt) + .slice(0, limit) + + for (const like of likes.value) { + void loadLikeMetadata(like.packageName) + } + } + + function addLike(like: RecentPackageLike) { + addLikes([{ ...like, animateEntry: true }]) + } + + function addInitialLikes(newLikes: RecentPackageLike[]) { + clearInitialLikeTimers() + + const stagedLikes = [...newLikes] + .sort((a, b) => b.likedAt - a.likedAt) + .slice(0, limit) + .sort((a, b) => a.likedAt - b.likedAt) + + if (stagedLikes.length === 0) { + isLoadingInitialLikes.value = false + return + } + + stagedLikes.forEach((like, index) => { + const timer = setTimeout(() => { + initialLikeTimers.delete(timer) + addLikes([{ ...like, animateEntry: true }]) + + if (index === stagedLikes.length - 1) { + isLoadingInitialLikes.value = false + } + }, index * INITIAL_PACKAGE_LIKE_STAGGER_MS) + + initialLikeTimers.add(timer) + }) + } + + async function loadInitialLikes() { + try { + const response = await fetch(createPackageLikesSeedUrl()) + if (!response.ok) { + isLoadingInitialLikes.value = false + return + } + + addInitialLikes(parseUfosPackageLikeRecords(await response.json())) + } catch (error) { + // The live Spacedust stream remains useful even if the UFOs seed request fails. + if (import.meta.dev && !import.meta.test) { + console.warn('[recent-package-likes] Failed to load initial likes:', error) + } + isLoadingInitialLikes.value = false + } + } + + function applyLikeMetadata(packageName: string, metadata: RecentPackageLikeMetadata) { + likes.value = likes.value.map(like => + like.packageName === packageName + ? { + ...like, + ...metadata, + } + : like, + ) + } + + async function loadLikeMetadata(packageName: string) { + if (metadataRequests.has(packageName)) return + metadataRequests.add(packageName) + + const encodedPackageName = encodePackageName(packageName) + const parsedMeta = await $fetch(`/api/registry/package-meta/${encodedPackageName}`, { + query: { includeRepositoryStars: 'true' }, + }) + .then(payload => v.safeParse(PackageMetaResponseSchema, payload)) + .catch(error => { + if (import.meta.dev && !import.meta.test) { + console.warn(`[recent-package-likes] Failed to load metadata for ${packageName}:`, error) + } + return null + }) + + const output = parsedMeta?.success ? parsedMeta.output : null + const metadata: RecentPackageLikeMetadata = { + packageDescription: output?.description || null, + weeklyDownloads: output?.weeklyDownloads ?? null, + repositoryStars: output?.repositoryStars ?? null, + } + + metadataByPackageName.set(packageName, metadata) + applyLikeMetadata(packageName, metadata) + } + + function handleMessage(event: MessageEvent) { + if (typeof event.data !== 'string') return + + let payload: unknown + try { + payload = JSON.parse(event.data) + } catch { + return + } + + const like = parseSpacedustPackageLikeEvent(payload) + if (like?.origin === 'live') addLike(like) + } + + const { open: openLikesStream } = useWebSocket(createPackageLikesStreamUrl(), { + autoConnect: false, + autoReconnect: { + delay: retried => Math.min(30_000, 1_000 * 2 ** Math.max(0, retried - 1)), + }, + immediate: false, + onError: ws => { + ws.close() + }, + onMessage: (_ws, event) => { + handleMessage(event) + }, + }) + + onMounted(() => { + void loadInitialLikes() + if (import.meta.client) openLikesStream() + }) + onScopeDispose(clearInitialLikeTimers) + + return { + isLoadingInitialLikes: readonly(isLoadingInitialLikes), + likes: readonly(likes), + } +} diff --git a/app/pages/index.vue b/app/pages/index.vue index 90d6e38879..28c74fba67 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -3,6 +3,8 @@ import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks' const { model: searchQuery, startSearch } = useGlobalSearch() const isSearchFocused = shallowRef(false) +const { isLoadingInitialLikes: isLoadingRecentPackageLikes, likes: recentPackageLikes } = + useRecentPackageLikes() async function search() { startSearch() @@ -88,6 +90,11 @@ defineOgImage('Splash.takumi', {}, { alt: () => $t('seo.home.description') }) +