forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline'
3import { useCssVariables } from '~/composables/useColors'
4import type { WeeklyDataPoint } from '~/types/chart'
5import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '~/utils/colors'
6import type { RepoRef } from '#shared/utils/git-providers'
7
8const props = defineProps<{
9 packageName: string
10 createdIso: string | null
11 repoRef?: RepoRef | null | undefined
12}>()
13
14const router = useRouter()
15const route = useRoute()
16
17const chartModal = useModal('chart-modal')
18const hasChartModalTransitioned = shallowRef(false)
19
20const modalTitle = computed(() => {
21 const facet = route.query.facet as string | undefined
22 if (facet === 'likes') return $t('package.trends.items.likes')
23 if (facet === 'contributors') return $t('package.trends.items.contributors')
24 return $t('package.trends.items.downloads')
25})
26
27const isChartModalOpen = shallowRef<boolean>(false)
28
29function handleModalClose() {
30 isChartModalOpen.value = false
31 hasChartModalTransitioned.value = false
32
33 router.replace({
34 query: {
35 ...route.query,
36 modal: undefined,
37 granularity: undefined,
38 end: undefined,
39 start: undefined,
40 facet: undefined,
41 },
42 })
43}
44
45function handleModalTransitioned() {
46 hasChartModalTransitioned.value = true
47}
48
49const { fetchPackageDownloadEvolution } = useCharts()
50
51const { accentColors, selectedAccentColor } = useAccentColor()
52
53const colorMode = useColorMode()
54
55const resolvedMode = shallowRef<'light' | 'dark'>('light')
56
57const rootEl = shallowRef<HTMLElement | null>(null)
58
59onMounted(() => {
60 rootEl.value = document.documentElement
61 resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light'
62})
63
64watch(
65 () => colorMode.value,
66 value => {
67 resolvedMode.value = value === 'dark' ? 'dark' : 'light'
68 },
69 { flush: 'sync' },
70)
71
72const { colors } = useCssVariables(
73 [
74 '--bg',
75 '--fg',
76 '--bg-subtle',
77 '--bg-elevated',
78 '--border-hover',
79 '--fg-subtle',
80 '--border',
81 '--border-subtle',
82 ],
83 {
84 element: rootEl,
85 watchHtmlAttributes: true,
86 watchResize: false, // set to true only if a var changes color on resize
87 },
88)
89
90const isDarkMode = computed(() => resolvedMode.value === 'dark')
91
92const accentColorValueById = computed<Record<string, string>>(() => {
93 const map: Record<string, string> = {}
94 for (const item of accentColors.value) {
95 map[item.id] = item.value
96 }
97 return map
98})
99
100const accent = computed(() => {
101 const id = selectedAccentColor.value
102 return id
103 ? (accentColorValueById.value[id] ?? colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK)
104 : (colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK)
105})
106
107const pulseColor = computed(() => {
108 if (!selectedAccentColor.value) {
109 return colors.value.fgSubtle
110 }
111 return isDarkMode.value ? accent.value : lightenOklch(accent.value, 0.5)
112})
113
114const weeklyDownloads = shallowRef<WeeklyDataPoint[]>([])
115const isLoadingWeeklyDownloads = shallowRef(true)
116const hasWeeklyDownloads = computed(() => weeklyDownloads.value.length > 0)
117
118async function openChartModal() {
119 if (!hasWeeklyDownloads.value) return
120
121 isChartModalOpen.value = true
122 hasChartModalTransitioned.value = false
123
124 await router.replace({
125 query: {
126 ...route.query,
127 modal: 'chart',
128 },
129 })
130
131 // ensure the component renders before opening the dialog
132 await nextTick()
133 await nextTick()
134 chartModal.open()
135}
136
137async function loadWeeklyDownloads() {
138 if (!import.meta.client) return
139
140 isLoadingWeeklyDownloads.value = true
141 try {
142 const result = await fetchPackageDownloadEvolution(
143 () => props.packageName,
144 () => props.createdIso,
145 () => ({ granularity: 'week' as const, weeks: 52 }),
146 )
147 weeklyDownloads.value = (result as WeeklyDataPoint[]) ?? []
148 } catch {
149 weeklyDownloads.value = []
150 } finally {
151 isLoadingWeeklyDownloads.value = false
152 }
153}
154
155onMounted(async () => {
156 await loadWeeklyDownloads()
157
158 if (route.query.modal === 'chart') {
159 isChartModalOpen.value = true
160 }
161
162 if (isChartModalOpen.value && hasWeeklyDownloads.value) {
163 openChartModal()
164 }
165})
166
167watch(
168 () => props.packageName,
169 () => loadWeeklyDownloads(),
170)
171
172const dataset = computed(() =>
173 weeklyDownloads.value.map(d => ({
174 value: d?.value ?? 0,
175 period: $t('package.trends.date_range', {
176 start: d.weekStart ?? '-',
177 end: d.weekEnd ?? '-',
178 }),
179 })),
180)
181
182const lastDatapoint = computed(() => dataset.value.at(-1)?.period ?? '')
183
184const config = computed(() => {
185 return {
186 theme: 'dark',
187 /**
188 * The built-in skeleton loader kicks in when the component is mounted but the data is not yet ready.
189 * The configuration of the skeleton is customized for a seemless transition with the final state
190 */
191 skeletonConfig: {
192 style: {
193 backgroundColor: 'transparent',
194 dataLabel: {
195 show: true,
196 color: 'transparent',
197 },
198 area: {
199 color: colors.value.borderHover,
200 useGradient: false,
201 opacity: 10,
202 },
203 line: {
204 color: colors.value.borderHover,
205 },
206 },
207 },
208 // Same idea: initialize the line at zero, so it nicely transitions to the final dataset
209 skeletonDataset: Array.from({ length: 52 }, () => 0),
210 style: {
211 backgroundColor: 'transparent',
212 animation: { show: false },
213 area: {
214 color: colors.value.borderHover,
215 useGradient: false,
216 opacity: 10,
217 },
218 dataLabel: {
219 offsetX: -10,
220 fontSize: 28,
221 bold: false,
222 color: colors.value.fg,
223 },
224 line: {
225 color: colors.value.borderHover,
226 pulse: {
227 show: true, // the pulse will not show if prefers-reduced-motion (enforced by vue-data-ui)
228 loop: true, // runs only once if false
229 radius: 1.5,
230 color: pulseColor.value,
231 easing: 'ease-in-out',
232 trail: {
233 show: true,
234 length: 20,
235 opacity: 0.75,
236 },
237 },
238 },
239 plot: {
240 radius: 6,
241 stroke: isDarkMode.value ? 'oklch(0.985 0 0)' : 'oklch(0.145 0 0)',
242 },
243 title: {
244 text: lastDatapoint.value,
245 fontSize: 12,
246 color: colors.value.fgSubtle,
247 bold: false,
248 },
249 verticalIndicator: {
250 strokeDasharray: 0,
251 color: isDarkMode.value ? 'oklch(0.985 0 0)' : colors.value.fgSubtle,
252 },
253 },
254 }
255})
256</script>
257
258<template>
259 <div class="space-y-8">
260 <CollapsibleSection id="downloads" :title="$t('package.downloads.title')">
261 <template #actions>
262 <ButtonBase
263 v-if="hasWeeklyDownloads"
264 type="button"
265 @click="openChartModal"
266 class="text-fg-subtle hover:text-fg transition-colors duration-200 inline-flex items-center justify-center min-w-6 min-h-6 -m-1 p-1 focus-visible:outline-accent/70 rounded"
267 :title="$t('package.trends.title')"
268 classicon="i-lucide:chart-line"
269 >
270 <span class="sr-only">{{ $t('package.trends.title') }}</span>
271 </ButtonBase>
272 <span v-else-if="isLoadingWeeklyDownloads" class="min-w-6 min-h-6 -m-1 p-1" />
273 </template>
274
275 <div class="w-full overflow-hidden">
276 <template v-if="isLoadingWeeklyDownloads || hasWeeklyDownloads">
277 <ClientOnly>
278 <VueUiSparkline class="w-full max-w-xs" :dataset :config>
279 <template #skeleton>
280 <!-- This empty div overrides the default built-in scanning animation on load -->
281 <div />
282 </template>
283 </VueUiSparkline>
284 <template #fallback>
285 <!-- Skeleton matching VueUiSparkline layout (title 24px + SVG aspect 500:80) -->
286 <div class="max-w-xs">
287 <!-- Title row: fontSize * 2 = 24px -->
288 <div class="h-6 flex items-center ps-3">
289 <SkeletonInline class="h-3 w-36" />
290 </div>
291 <!-- Chart area: matches SVG viewBox 500:80 -->
292 <div class="aspect-[500/80] flex items-center">
293 <!-- Data label (covers ~42% width, matching dataLabel.offsetX) -->
294 <div class="w-[42%] flex items-center ps-0.5">
295 <SkeletonInline class="h-7 w-24" />
296 </div>
297 <!-- Sparkline line placeholder -->
298 <div class="flex-1 flex items-end pe-3">
299 <SkeletonInline class="h-px w-full" />
300 </div>
301 </div>
302 </div>
303 </template>
304 </ClientOnly>
305 </template>
306 <p v-else class="py-2 text-sm font-mono text-fg-subtle">
307 {{ $t('package.trends.no_data') }}
308 </p>
309 </div>
310 </CollapsibleSection>
311 </div>
312
313 <PackageChartModal
314 v-if="isChartModalOpen && hasWeeklyDownloads"
315 :modal-title="modalTitle"
316 @close="handleModalClose"
317 @transitioned="handleModalTransitioned"
318 >
319 <!-- The Chart is mounted after the dialog has transitioned -->
320 <!-- This avoids flaky behavior that hides the chart's minimap half of the time -->
321 <Transition name="opacity" mode="out-in">
322 <PackageTrendsChart
323 v-if="hasChartModalTransitioned"
324 :weeklyDownloads="weeklyDownloads"
325 :inModal="true"
326 :packageName="props.packageName"
327 :repoRef="props.repoRef"
328 :createdIso="createdIso"
329 permalink
330 show-facet-selector
331 />
332 </Transition>
333
334 <!-- This placeholder bears the same dimensions as the PackageTrendsChart component -->
335 <!-- Avoids CLS when the dialog has transitioned -->
336 <div
337 v-if="!hasChartModalTransitioned"
338 class="w-full aspect-[390/634.5] sm:aspect-[718/622.797]"
339 />
340 </PackageChartModal>
341</template>
342
343<style scoped>
344.opacity-enter-active,
345.opacity-leave-active {
346 transition: opacity 200ms ease;
347}
348
349.opacity-enter-from,
350.opacity-leave-to {
351 opacity: 0;
352}
353
354.opacity-enter-to,
355.opacity-leave-from {
356 opacity: 1;
357}
358</style>
359
360<style>
361/** Overrides */
362.vue-ui-sparkline-title span {
363 padding: 0 !important;
364 letter-spacing: 0.04rem;
365}
366
367.vue-ui-sparkline text {
368 font-family:
369 Geist Mono,
370 monospace !important;
371}
372</style>