[READ-ONLY] a fast, modern browser for the npm registry
at main 372 lines 10 kB view raw
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>