Skip to content
Draft
16 changes: 16 additions & 0 deletions app/components/Package/Dependencies.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ const { t } = useI18n()
const props = defineProps<{
packageName: string
version: string
packageSize?: InstallSizeResult | undefined
dependencies?: Record<string, string>
peerDependencies?: Record<string, string>
peerDependenciesMeta?: Record<string, { optional?: boolean }>
optionalDependencies?: Record<string, string>
bundledDependencies?: boolean | string[]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}>()

// Fetch outdated info for dependencies
Expand Down Expand Up @@ -121,6 +123,20 @@ const numberFormatter = useNumberFormatter()
)
"
>
<div class="mb-4">
<div class="flex items-center justify-between mb-3">
<div class="text-xs text-fg-subtle uppercase tracking-wider font-medium">
{{ $t('package.stats.install_size') }}
</div>
</div>
<PackageSizeBar
:package-name="props.packageName"
:version="props.version"
:package-size="props.packageSize"
:dependencies="props.dependencies"
:bundled-dependencies="props.bundledDependencies"
/>
</div>
<ul class="space-y-1 list-none m-0" :aria-label="$t('package.dependencies.list_label')">
<li
v-for="[dep, version] in visibleDeps"
Expand Down
140 changes: 140 additions & 0 deletions app/components/Package/SizeBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<script setup lang="ts">
import type { InstallSizeResult } from '#shared/types/install-size'

const props = withDefaults(
defineProps<{
packageName: string
version: string
packageSize?: InstallSizeResult | undefined
dependencies?: Record<string, string>
bundledDependencies?: boolean | string[]
height?: string
}>(),
{
height: 'h-6',
},
)

const { data: sizereqData } = usePackageDependencySizes(
props.packageName,
props.version,
props.packageSize?.dependencies,
)

// Minimum percentage to be shown as an individual slice
const THRESHOLD_PERCENT = 2

type Sizereq = {
info: InstallSizeResult
bundled: boolean
percent: number
}

// Process dependencies for size visualization
const sortedSizereqDependecies = computed(() => {
if (!props.packageSize?.totalSize || !props.packageSize.dependencies) {
return { visible: [], others: [], totalOthersSize: 0, othersPercentage: 0 }
}

const allMapped = props.packageSize.dependencies.map(depSize => {
let bundled = false
switch (typeof props.bundledDependencies) {
case 'boolean':
bundled = props.bundledDependencies
break
case 'object':
bundled = props.bundledDependencies.some(name => name === depSize.name)
break
}
const percent = props.packageSize ? (depSize.size / props.packageSize.totalSize) * 100 : 0
const serverData = sizereqData.value?.[depSize.name]
return {
info:
serverData?.kind === 'success' && serverData.packageSize
? {
package: depSize.name,
version: depSize.version,
totalSize: serverData.packageSize.totalSize,
selfSize: serverData.packageSize.selfSize,
}
: {
package: depSize.name,
version: depSize.version,
totalSize: depSize.size,
selfSize: depSize.size,
},
bundled,
percent,
} as Sizereq
})

const visible: Sizereq[] = []
const others: Sizereq[] = []

for (const dep of allMapped) {
const percentage = (dep.info.selfSize / props.packageSize.totalSize) * 100
if (percentage >= THRESHOLD_PERCENT) {
visible.push({ ...dep, percent: percentage })
} else {
others.push(dep)
}
}

const othersSelfSize = others.reduce((acc, d) => acc + d.info.selfSize, 0)
const othersPercentage = (othersSelfSize / props.packageSize.totalSize) * 100

return { visible, others, totalOthersSize: othersSelfSize, othersPercentage }
})

const selfSizeWidth = computed(() => {
if (!props.packageSize?.selfSize || !props.packageSize?.totalSize) return 0
return (props.packageSize.selfSize / props.packageSize.totalSize) * 100
})

const remainingWidth = computed(() => {
const total = props.packageSize?.totalSize
if (!total) return 100

const self = props.packageSize.selfSize || 0
const depsSum = [
...sortedSizereqDependecies.value.visible,
...sortedSizereqDependecies.value.others,
].reduce((acc, d) => acc + d.info.selfSize, 0)

const width = ((total - (self + depsSum)) / total) * 100
return Math.max(0, width)
})
</script>

<template>
<div
:class="[
props.height,
'gap-0.5 flex flex-row w-full bg-fg-muted/10 overflow-hidden rounded-md',
]"
>
<div
v-if="selfSizeWidth > 0"
class="h-full bg-accent"
:style="{ width: selfSizeWidth + '%' }"
/>

<template v-for="dep in sortedSizereqDependecies.visible" :key="dep.info.package">
<div
class="h-full"
:class="dep.bundled ? 'bg-accent' : 'bg-fg'"
:style="{ width: dep.percent + '%' }"
/>
</template>

<div
v-if="sortedSizereqDependecies.others.length > 0"
class="h-full bg-fg flex items-center justify-center"
:style="{ width: sortedSizereqDependecies.othersPercentage + '%' }"
>
<span class="i-lucide:network w-3 h-3 text-bg" aria-hidden="true" />
</div>

<div v-if="remainingWidth > 0" class="h-full bg-bg-elevated animate-skeleton-pulse flex-1" />
</div>
</template>
150 changes: 150 additions & 0 deletions app/components/Package/SizeCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<script setup lang="ts">
import type { SizeEntry } from '~/types/size'
import { getSizeRoute, packageRoute } from '~/utils/router'

const props = defineProps<{
entry: SizeEntry
}>()

const { t } = useI18n()
const numberFormatter = useNumberFormatter()
const bytesFormatter = useBytesFormatter()

const target = useTemplateRef('target')
const targetIsVisible = shallowRef(false)

const { stop } = useIntersectionObserver(target, ([entry]) => {
if ((targetIsVisible.value = entry?.isIntersecting || false)) stop()
})

const isSizeUnknown = computed(() => Number.isNaN(props.entry.totalSize))

const { data: fetchedSize, execute } = usePackageSize(
() => props.entry.name,
() => props.entry.version,
{ immediate: false },
)

watch(targetIsVisible, visible => {
if (visible) execute()
})

const displayTotalSize = computed(() => {
if (!isSizeUnknown.value) return props.entry.totalSize
return fetchedSize.value?.totalSize ?? NaN
})

const displayDepCount = computed(() => {
if (!isSizeUnknown.value) return props.entry.depCount
return fetchedSize.value?.dependencies?.length ?? props.entry.depCount
})

const packageSizeData = computed(() => {
return (
fetchedSize.value || {
package: props.entry.name,
version: props.entry.version,
selfSize: props.entry.selfSize,
totalSize: displayTotalSize.value,
dependencyCount: displayDepCount.value,
dependencies: [],
}
)
})
</script>

<template>
<div ref="target">
<BaseCard>
<header class="mb-4 flex items-baseline justify-between gap-2">
<h3
class="font-mono text-sm sm:text-base font-medium text-fg group-hover:text-fg transition-colors duration-200 min-w-0 break-all"
>
<NuxtLink
:to="packageRoute(entry.name, entry.version)"
class="decoration-none after:content-[''] after:absolute after:inset-0"
dir="ltr"
>{{ entry.name }}</NuxtLink
>
</h3>

<LinkBase
variant="button-secondary"
size="sm"
:to="getSizeRoute(entry.name, entry.version)"
classicon="i-lucide:package-open"
class="relative z-10 whitespace-nowrap gap-2 px-3 py-1.5"
>
<span class="text-xs font-medium">{{ t('package.stats.view_all_sizes') }}</span>
</LinkBase>
</header>

<div class="flex flex-col sm:flex-row sm:justify-start sm:items-start gap-6 sm:gap-8">
<div class="min-w-0 w-full">
<!-- Version -->
<div class="text-xs text-fg-subtle font-mono mb-2 sm:mb-3">v{{ entry.version }}</div>
<!-- Stats row -->
<div class="flex flex-wrap items-center gap-x-3 sm:gap-x-4 gap-y-2 text-xs text-fg-muted">
<dl class="flex items-center gap-4 m-0">
<!-- Self size -->
<div class="flex items-center gap-1.5">
<dt class="sr-only">{{ t('package.sizes.columns.self_size') }}</dt>
<dd class="font-mono">{{ bytesFormatter.format(entry.selfSize) }}</dd>
</div>

<!-- Total size -->
<div class="flex items-center gap-1.5">
<dt class="sr-only">{{ t('package.stats.install_size') }}</dt>
<dd class="font-mono">
<template v-if="!Number.isNaN(displayTotalSize)">
{{
t('package.stats.size.total', {
size: bytesFormatter.format(displayTotalSize),
})
}}
</template>
<div
v-else-if="targetIsVisible"
class="inline-block w-3 h-3 border-2 border-fg-muted/20 border-t-accent rounded-full animate-spin"
aria-hidden="true"
/>
</dd>
</div>

<!-- Dep count -->
<div class="flex items-center gap-1.5">
<dt class="sr-only">{{ t('package.stats.deps') }}</dt>
<dd class="flex items-center gap-1.5">
<span class="i-lucide:network w-3.5 h-3.5" aria-hidden="true" />
<template v-if="!Number.isNaN(displayTotalSize)">
<span class="font-mono">{{ numberFormatter.format(displayDepCount) }}</span>
</template>
<div
v-else-if="targetIsVisible"
class="inline-block w-3 h-3 border-2 border-fg-muted/20 border-t-accent rounded-full animate-spin"
aria-hidden="true"
/>
</dd>
</div>

<!-- Percentage -->
<div v-if="entry.percentage" class="flex items-center gap-1.5">
<dt class="sr-only">{{ t('package.sizes.columns.percentage') }}</dt>
<dd class="font-mono">{{ numberFormatter.format(entry.percentage) }}%</dd>
</div>
</dl>
</div>
</div>
</div>

<!-- Size bar -->
<div v-if="!Number.isNaN(displayTotalSize)" class="mt-3 pt-3 border-t border-border">
<PackageSizeBar
:package-name="entry.name"
:version="entry.version"
:package-size="packageSizeData"
/>
</div>
</BaseCard>
</div>
</template>
Loading
Loading