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
24 changes: 22 additions & 2 deletions app/components/Package/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const props = defineProps<{
latestVersion?: SlimVersion | null
provenanceData?: ProvenanceDetails | null
provenanceStatus?: string | null
page: 'main' | 'docs' | 'code' | 'diff'
page: 'main' | 'docs' | 'code' | 'diff' | 'dependents'
versionUrlPattern: string
}>()

Expand Down Expand Up @@ -108,6 +108,18 @@ const mainLink = computed((): RouteLocationRaw | null => {
return packageRoute(props.pkg.name, props.resolvedVersion)
})

const dependentsLink = computed((): RouteLocationRaw | null => {
if (props.pkg == null) return null
const split = props.pkg.name.split('/')
return {
name: 'package-dependents',
params: {
org: split.length === 2 ? split[0] : undefined,
name: split.length === 2 ? split[1]! : split[0]!,
},
}
})

const diffLink = computed((): RouteLocationRaw | null => {
if (
props.pkg == null ||
Expand Down Expand Up @@ -271,7 +283,7 @@ const fundingUrl = computed(() => {
:to="packageRoute(packageName, resolvedVersion, '#provenance')"
:aria-label="$t('package.provenance_section.view_more_details')"
classicon="i-lucide:shield-check"
class="py-1.25 px-2 me-2"
class="py-1.5 px-2 me-2"
/>
</TooltipApp>
</template>
Expand Down Expand Up @@ -343,6 +355,14 @@ const fundingUrl = computed(() => {
>
{{ $t('compare.compare_versions') }}
</LinkBase>
<LinkBase
v-if="dependentsLink"
:to="dependentsLink"
class="decoration-none border-b-2 p-1 hover:border-accent/50 focus-visible:[outline-offset:-2px]!"
:class="page === 'dependents' ? 'border-accent text-accent!' : 'border-transparent'"
>
{{ $t('package.links.dependents') }}
</LinkBase>
</nav>
</div>
</div>
Expand Down
183 changes: 183 additions & 0 deletions app/pages/package/[[org]]/[name]/dependents.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<script setup lang="ts">
definePageMeta({
name: 'package-dependents',
scrollMargin: 200,
})

const route = useRoute('package-dependents')

const packageName = computed(() => {
const { org, name } = route.params
return org ? `${org}/${name}` : name
})

const { data: pkg } = usePackage(packageName)

const resolvedVersion = computed(() => {
const latest = pkg.value?.['dist-tags']?.latest
if (!latest) return null
return latest
})

const displayVersion = computed(() => pkg.value?.requestedVersion ?? null)

const latestVersion = computed(() => {
if (!pkg.value) return null
const latestTag = pkg.value['dist-tags']?.latest
if (!latestTag) return null
return pkg.value.versions[latestTag] ?? null
})

const versionUrlPattern = computed(() => {
const split = packageName.value.split('/')
if (split.length === 2) {
return `/package/${split[0]}/${split[1]}/v/{version}`
}
return `/package/${packageName.value}/v/{version}`
})

const page = shallowRef(0)
const PAGE_SIZE = 20

interface DependentsResponse {
total: number
page: number
size: number
packages: Array<{
name: string
version: string
description: string | null
date: string | null
score: number
}>
}

const { data, status, refresh } = useLazyFetch<DependentsResponse>(
() => `/api/registry/dependents/${packageName.value}`,
{
query: computed(() => ({ page: page.value, size: PAGE_SIZE })),
watch: [page],
},
)

const totalPages = computed(() => {
if (!data.value?.total) return 0
return Math.ceil(data.value.total / PAGE_SIZE)
})

function prevPage() {
if (page.value > 0) {
page.value--
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}

function nextPage() {
if (page.value < totalPages.value - 1) {
page.value++
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}

const numberFormatter = useNumberFormatter()

useSeoMeta({
title: () => `Dependents - ${packageName.value} - npmx`,
description: () => `Packages that depend on ${packageName.value}`,
})
</script>

<template>
<main class="flex-1 pb-8">
<PackageHeader
:pkg="pkg ?? null"
:resolved-version="resolvedVersion"
:display-version="displayVersion"
:latest-version="latestVersion"
:version-url-pattern="versionUrlPattern"
page="dependents"
/>

<div class="container py-6">
<h1 class="font-mono text-xl font-semibold mb-1">
{{ $t('package.dependents.title') }}
</h1>
<p class="text-sm text-fg-muted mb-6">
{{ $t('package.dependents.subtitle', { name: packageName }) }}
</p>

<!-- Loading state -->
<div v-if="status === 'pending'" class="space-y-2">
<SkeletonInline v-for="i in 10" :key="i" class="h-16 w-full rounded-md" />
</div>

<!-- Error state -->
<div v-else-if="status === 'error'" class="py-12 text-center">
<p class="text-fg-muted mb-4">{{ $t('package.dependents.error') }}</p>
<ButtonBase @click="refresh()">{{ $t('common.retry') }}</ButtonBase>
</div>

<!-- Empty state -->
<div v-else-if="!data?.packages?.length" class="py-12 text-center">
<span class="i-lucide:package-x w-12 h-12 mx-auto mb-4 text-fg-subtle block" />
<p class="text-fg-muted">{{ $t('package.dependents.none', { name: packageName }) }}</p>
</div>

<!-- Results -->
<template v-else>
<p class="text-xs text-fg-subtle mb-4 font-mono">
{{
$t(
'package.dependents.count',
{ count: numberFormatter.format(data.total) },
data.total,
)
}}
</p>

<ul class="space-y-2 list-none m-0 p-0">
<li
v-for="dep in data.packages"
:key="dep.name"
class="border border-border rounded-md p-4 hover:border-border-hover transition-colors"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<LinkBase
:to="packageRoute(dep.name)"
class="font-mono text-sm font-medium"
dir="ltr"
>
{{ dep.name }}
</LinkBase>
<p v-if="dep.description" class="text-xs text-fg-muted mt-1 line-clamp-2">
{{ dep.description }}
</p>
</div>
<span class="font-mono text-xs text-fg-subtle shrink-0" dir="ltr">
{{ dep.version }}
</span>
</div>
</li>
</ul>

<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between mt-6">
<ButtonBase
variant="secondary"
classicon="i-lucide:chevron-left"
:disabled="page === 0"
@click="prevPage"
>
{{ $t('common.previous') }}
</ButtonBase>
<span class="text-sm text-fg-muted font-mono"> {{ page + 1 }} / {{ totalPages }} </span>
<ButtonBase variant="secondary" :disabled="page >= totalPages - 1" @click="nextPage">
{{ $t('common.next') }}
<span class="i-lucide:chevron-right w-4 h-4" aria-hidden="true" />
</ButtonBase>
</div>
</template>
</div>
</main>
</template>
12 changes: 11 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@
"members": "members"
},
"scroll_to_top": "Scroll to top",
"previous": "Previous",
"next": "Next",
"cancel": "Cancel",
"save": "Save",
"edit": "Edit",
Expand Down Expand Up @@ -318,7 +320,8 @@
"docs": "docs",
"fund": "fund",
"compare": "compare",
"compare_this_package": "compare this package"
"compare_this_package": "compare this package",
"dependents": "dependents"
},
"likes": {
"like": "Like this package",
Expand Down Expand Up @@ -634,6 +637,13 @@
"download": {
"button": "Download",
"tarball": "Download Tarball as .tar.gz"
},
"dependents": {
"title": "Dependents",
"subtitle": "Packages that depend on {name}",
"count": "{count} dependent | {count} dependents",
"none": "No packages found that depend on {name}",
"error": "Failed to load dependents"
}
},
"connector": {
Expand Down
30 changes: 30 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,12 @@
"scroll_to_top": {
"type": "string"
},
"previous": {
"type": "string"
},
"next": {
"type": "string"
},
"cancel": {
"type": "string"
},
Expand Down Expand Up @@ -960,6 +966,9 @@
},
"compare_this_package": {
"type": "string"
},
"dependents": {
"type": "string"
}
},
"additionalProperties": false
Expand Down Expand Up @@ -1908,6 +1917,27 @@
}
},
"additionalProperties": false
},
"dependents": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"subtitle": {
"type": "string"
},
"count": {
"type": "string"
},
"none": {
"type": "string"
},
"error": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
Expand Down
Loading
Loading