diff --git a/README.md b/README.md index 79d2c4f87f..a61932601d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ What npmx offers: - **Dark mode and light mode** – plus customize the color palette to your preferences - **Translated interface** – localized UI across 39+ locales, including RTL support - **First-class accessibility** – accessible components, keyboard workflows, and automated axe/Lighthouse checks -- **URL-driven feature views** – share exact package versions, search results, compare sets, source files and lines, diffs, docs, changelogs, and timelines +- **URL-driven feature views** – share exact package versions, search results, compare sets, source files and lines, diffs, docs, changelogs, stats and timelines - **Fast search** – quick package search with instant results - **Package details** – READMEs, versions, dependencies, and metadata - **Code viewer** – browse package source code with syntax highlighting and permalink to specific lines diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue index d493ff2cd2..2ee2a47f19 100644 --- a/app/components/AppFooter.vue +++ b/app/components/AppFooter.vue @@ -244,6 +244,10 @@ const footerSections = computed>(( t {{ $t('shortcuts.open_timeline') }} +
  • + s + {{ $t('shortcuts.open_stats') }} +
  • c {{ $t('shortcuts.compare_from_package') }} diff --git a/app/components/Package/ChartModal.vue b/app/components/Package/ChartModal.vue deleted file mode 100644 index 598291c894..0000000000 --- a/app/components/Package/ChartModal.vue +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index b42f27ffd1..aa7c351292 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -11,7 +11,7 @@ const props = defineProps<{ latestVersion?: SlimVersion | null provenanceData?: ProvenanceDetails | null provenanceStatus?: string | null - page: 'main' | 'docs' | 'code' | 'diff' | 'changelog' | 'timeline' + page: 'main' | 'docs' | 'code' | 'diff' | 'changelog' | 'timeline' | 'stats' versionUrlPattern: string }>() @@ -182,6 +182,11 @@ const timelineLink = computed((): RouteLocationRaw | null => { return packageTimelineRoute(props.pkg.name, props.resolvedVersion) }) +const statsLink = computed((): RouteLocationRaw | null => { + if (props.pkg == null || props.resolvedVersion == null) return null + return packageStatsRoute(props.pkg.name, props.resolvedVersion) +}) + useShortcuts({ '.': () => codeLink.value, 'm': () => mainLink.value, @@ -190,6 +195,7 @@ useShortcuts({ 'f': () => diffLink.value, '-': () => changelogLink.value, 't': () => timelineLink.value, + 's': () => statsLink.value, }) @@ -370,6 +376,15 @@ useShortcuts({ > {{ $t('package.links.timeline') }} + + {{ $t('package.links.stats') }} + diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 1b7801fed8..fdd2d10eaa 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -52,8 +52,10 @@ const props = withDefaults( /** When true, shows facet selector (e.g. Downloads / Likes). */ showFacetSelector?: boolean permalink?: boolean + defaultRange?: 'auto' | '52-weeks' }>(), { + defaultRange: 'auto', permalink: false, }, ) @@ -206,19 +208,21 @@ const { const repoRefsByPackage = shallowRef>({}) const repoRefsRequestToken = shallowRef(0) +const repoRefsPending = shallowRef(false) watch( () => effectivePackageNames.value, async names => { if (!import.meta.client) return - if (!isMultiPackageMode.value) { - repoRefsByPackage.value = {} - return - } const currentToken = ++repoRefsRequestToken.value - const refs = await fetchRepoRefsForPackages(names) - if (currentToken !== repoRefsRequestToken.value) return - repoRefsByPackage.value = refs + repoRefsPending.value = true + try { + const refs = await fetchRepoRefsForPackages(names) + if (currentToken !== repoRefsRequestToken.value) return + repoRefsByPackage.value = refs + } finally { + if (currentToken === repoRefsRequestToken.value) repoRefsPending.value = false + } }, { immediate: true }, ) @@ -373,7 +377,7 @@ function addUtcDays(date: Date, days: number): Date { function initDateRangeForMultiPackageWeekly52() { if (hasUserEditedDates.value) return if (!import.meta.client) return - if (!isMultiPackageMode.value) return + if (!isMultiPackageMode.value && props.defaultRange === 'auto') return if (startDate.value && endDate.value) return const today = new Date() @@ -386,7 +390,7 @@ function initDateRangeForMultiPackageWeekly52() { } watch( - () => (props.packageNames ?? []).length, + () => (props.packageNames ?? []).length || props.defaultRange === '52-weeks', () => { initDateRangeForMultiPackageWeekly52() }, @@ -493,14 +497,6 @@ type MetricDef = { supportsMulti?: boolean } -const hasContributorsFacet = computed(() => { - if (isMultiPackageMode.value) { - return Object.values(repoRefsByPackage.value).some(ref => ref?.provider === 'github') - } - const ref = props.repoRef - return ref?.provider === 'github' && ref.owner && ref.repo -}) - const METRICS = computed(() => { const metrics: MetricDef[] = [ { @@ -520,16 +516,13 @@ const METRICS = computed(() => { fetch: ({ packageName }, opts) => fetchPackageLikesEvolution(packageName, opts), supportsMulti: true, }, - ] - - if (hasContributorsFacet.value) { - metrics.push({ + { id: 'contributors', label: $t('package.trends.items.contributors'), fetch: ({ repoRef }, opts) => fetchRepoContributorsEvolution(repoRef, opts), supportsMulti: true, - }) - } + }, + ] return metrics }) @@ -692,6 +685,10 @@ async function loadMetric(metricId: MetricId) { const currentToken = ++state.requestToken state.pending = true + if (metricId === 'contributors' && repoRefsPending.value) { + return + } + const fetchFn = (context: MetricContext) => metric.fetch(context, options.value) try { @@ -754,7 +751,10 @@ async function loadMetric(metricId: MetricId) { } } - const result = await fetchFn({ packageName: pkg, repoRef: props.repoRef }) + const result = await fetchFn({ + packageName: pkg, + repoRef: props.repoRef || repoRefsByPackage.value[pkg], + }) if (currentToken !== state.requestToken) return state.evolution = (result ?? []) as EvolutionData @@ -813,7 +813,6 @@ watch( () => { if (!import.meta.client) return if (!isMounted.value) return - if (!isMultiPackageMode.value) return if (selectedMetric.value !== 'contributors') return debouncedLoadNow() }, diff --git a/app/components/Package/VersionDistribution.vue b/app/components/Package/VersionDistribution.vue index 1bc957e243..badad67b4c 100644 --- a/app/components/Package/VersionDistribution.vue +++ b/app/components/Package/VersionDistribution.vue @@ -173,7 +173,7 @@ const chartConfig = computed(() => { backgroundColor: colors.value.bg, padding: { top: 24, - right: 24, + right: 145, bottom: 60, }, userOptions: { diff --git a/app/components/Package/Versions.vue b/app/components/Package/Versions.vue index 9e76ff93b4..f69af1efa4 100644 --- a/app/components/Package/Versions.vue +++ b/app/components/Package/Versions.vue @@ -12,73 +12,8 @@ const props = defineProps<{ selectedVersion?: string }>() -const QUERY_MODAL_VALUE = 'versions' -const chartModal = useModal('chart-modal') -const hasDistributionModalTransitioned = shallowRef(false) -const isDistributionModalOpen = shallowRef(false) -let distributionModalFallbackTimer: ReturnType | null = null - -function clearDistributionModalFallbackTimer() { - if (distributionModalFallbackTimer) { - clearTimeout(distributionModalFallbackTimer) - distributionModalFallbackTimer = null - } -} - -const router = useRouter() const route = useRoute() -async function openDistributionModal() { - isDistributionModalOpen.value = true - hasDistributionModalTransitioned.value = false - // ensure the component renders before opening the dialog - await nextTick() - chartModal.open() - - await router.replace({ - query: { - ...route.query, - modal: QUERY_MODAL_VALUE, - }, - }) - - // Fallback: Force mount if transition event doesn't fire - clearDistributionModalFallbackTimer() - distributionModalFallbackTimer = setTimeout(() => { - if (!hasDistributionModalTransitioned.value) { - hasDistributionModalTransitioned.value = true - } - }, 500) -} - -function closeDistributionModal() { - isDistributionModalOpen.value = false - - router.replace({ - query: { - ...route.query, - modal: undefined, - grouping: undefined, - recent: undefined, - lowUsage: undefined, - }, - }) - - hasDistributionModalTransitioned.value = false - clearDistributionModalFallbackTimer() -} - -onMounted(() => { - if (route.query.modal === QUERY_MODAL_VALUE) { - openDistributionModal() - } -}) - -function handleDistributionModalTransitioned() { - hasDistributionModalTransitioned.value = true - clearDistributionModalFallbackTimer() -} - /** Maximum number of dist-tag rows to show before collapsing into "Other versions" */ const MAX_VISIBLE_TAGS = 10 @@ -96,6 +31,13 @@ function versionRoute(version: string): RouteLocationRaw { return packageRoute(props.packageName, version) } +const distributionRoute = computed(() => { + if (route.name === 'stats') return null + const version = effectiveCurrentVersion.value || props.distTags.latest + if (!version) return null + return packageStatsRoute(props.packageName, version, '#distribution') +}) + // Route to the full versions history page const versionsPageRoute = computed(() => packageVersionsRoute(props.packageName)) @@ -540,15 +482,17 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b > {{ $t('package.versions.view_all_versions') }} - {{ $t('package.downloads.community_distribution') }} - +
    @@ -1124,41 +1068,4 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
    - - - - - - - - - - - -
    - - - diff --git a/app/components/Package/WeeklyDownloadStats.vue b/app/components/Package/WeeklyDownloadStats.vue index 77fdf6e7d3..6ed1514cd9 100644 --- a/app/components/Package/WeeklyDownloadStats.vue +++ b/app/components/Package/WeeklyDownloadStats.vue @@ -18,50 +18,11 @@ const props = defineProps<{ packageName: string createdIso: string | null repoRef?: RepoRef | null | undefined + version?: string | null }>() -const router = useRouter() -const route = useRoute() const { settings } = useSettings() -const chartModal = useModal('chart-modal') -const hasChartModalTransitioned = shallowRef(false) - -const modalTitle = computed(() => { - const facet = route.query.facet as string | undefined - if (facet === 'likes') return $t('package.trends.items.likes') - if (facet === 'contributors') return $t('package.trends.items.contributors') - return $t('package.trends.items.downloads') -}) - -const modalSubtitle = computed(() => { - const facet = route.query.facet as string | undefined - if (facet === 'likes' || facet === 'contributors') return undefined - return $t('package.downloads.subtitle') -}) - -const isChartModalOpen = shallowRef(false) - -function handleModalClose() { - isChartModalOpen.value = false - hasChartModalTransitioned.value = false - - router.replace({ - query: { - ...route.query, - modal: undefined, - granularity: undefined, - end: undefined, - start: undefined, - facet: undefined, - }, - }) -} - -function handleModalTransitioned() { - hasChartModalTransitioned.value = true -} - const { fetchPackageDownloadEvolution } = useCharts() const numberFormatter = useNumberFormatter() @@ -116,25 +77,6 @@ const weeklyDownloads = shallowRef([]) const isLoadingWeeklyDownloads = shallowRef(true) const hasWeeklyDownloads = computed(() => weeklyDownloads.value.length > 0) -async function openChartModal() { - if (!hasWeeklyDownloads.value) return - - isChartModalOpen.value = true - hasChartModalTransitioned.value = false - - await router.replace({ - query: { - ...route.query, - modal: 'chart', - }, - }) - - // ensure the component renders before opening the dialog - await nextTick() - await nextTick() - chartModal.open() -} - async function loadWeeklyDownloads() { if (!import.meta.client) return @@ -155,14 +97,6 @@ async function loadWeeklyDownloads() { onMounted(async () => { await loadWeeklyDownloads() - - if (route.query.modal === 'chart') { - isChartModalOpen.value = true - } - - if (isChartModalOpen.value && hasWeeklyDownloads.value) { - openChartModal() - } }) watch( @@ -194,6 +128,11 @@ const dataset = computed(() => })), ) +const trendsRoute = computed(() => { + if (!props.version) return null + return packageStatsRoute(props.packageName, props.version, '#trends') +}) + const lastDatapoint = computed(() => dataset.value.at(-1)?.period ?? '') const showPulse = shallowRef(true) @@ -399,16 +338,16 @@ const config = computed(() => { :subtitle="$t('package.downloads.subtitle')" > @@ -456,51 +395,9 @@ const config = computed(() => {
    - - - - - - - - - - -
    -