Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
8caba70
whatever
Mopsgamer May 26, 2026
66a29eb
fixup: fetch version correctly
Mopsgamer Aug 28, 2026
31c04b3
fixup: focus
Mopsgamer Aug 28, 2026
1737f38
fix: make /v/ver optional
Mopsgamer Aug 28, 2026
39f4d7c
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 28, 2026
4c121bc
rows
Mopsgamer Aug 28, 2026
2da5703
fix some issues, make only names clickable, fix vulnerabilities insight
Mopsgamer Aug 29, 2026
c3b8aa4
usePackageListPreferences
Mopsgamer Aug 29, 2026
d601dce
resolve more issues
Mopsgamer Aug 29, 2026
1aeea7e
fix i18n
Mopsgamer Aug 29, 2026
2a3e6a8
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 29, 2026
c52f338
v range
Mopsgamer Aug 29, 2026
83bd052
ranges minver (unsure); refactor usePackageDependencyInsights
Mopsgamer Aug 29, 2026
5bd5d98
fixup
Mopsgamer Aug 29, 2026
d5dbe06
fixup (missing .value strikes again)
Mopsgamer Aug 29, 2026
34b4fd1
move up
Mopsgamer Aug 30, 2026
3bf55b3
fixup
Mopsgamer Aug 30, 2026
13a35a7
improve table row skeleton
Mopsgamer Aug 30, 2026
7f78a5e
table reuse insights
Mopsgamer Aug 30, 2026
aff3337
i18n
Mopsgamer Aug 30, 2026
9ffcff8
no self +1, props.insights->insights
Mopsgamer Aug 30, 2026
3f15964
vuln key to key getter
Mopsgamer Aug 30, 2026
54e71ee
no focus-visible
Mopsgamer Aug 30, 2026
f1a2ba0
nl.json has_replacement
Mopsgamer Aug 30, 2026
a437a40
catch package error (not found, go back btn)
Mopsgamer Aug 30, 2026
78bdd65
don't replace with stale dep data
Mopsgamer Aug 30, 2026
6e30814
fix t usage and insights computation
Mopsgamer Aug 30, 2026
4074a3f
cmd palette deps icon
Mopsgamer Aug 30, 2026
e7157f5
improve focus (still using selectors)
Mopsgamer Aug 30, 2026
a7308c3
fixup: don't Enter on inputs
Mopsgamer Aug 30, 2026
3dcefe6
asc and columns
Mopsgamer Aug 30, 2026
b3e8202
filter by insights and dep type (multiselect)
Mopsgamer Aug 31, 2026
74df3a8
localization
Mopsgamer Aug 31, 2026
016b17a
fix row-link
Mopsgamer Aug 31, 2026
abf8bdb
revert localization
Mopsgamer Aug 31, 2026
317f881
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 31, 2026
7bf06fc
query params
Mopsgamer Aug 31, 2026
e455831
fix minor styling problems
Mopsgamer Aug 31, 2026
8f0e9a8
fix data construction
Mopsgamer Aug 31, 2026
cc5053e
fix hydration for rows
Mopsgamer Aug 31, 2026
b5be830
do not say "All" if there is only 1
Mopsgamer Aug 31, 2026
ad50739
404 bad version
Mopsgamer Aug 31, 2026
bfc4b53
parse spec
Mopsgamer Aug 31, 2026
49cc985
fix ver updates for aliases
Mopsgamer Aug 31, 2026
be965f2
generate green-text class
Mopsgamer Sep 1, 2026
7a8bec6
make cards and rows clickable again at any point except some elements…
Mopsgamer Sep 1, 2026
a0be8ea
now remove green and patch info for 'latest'
Mopsgamer Sep 1, 2026
7a241be
make it client only
Mopsgamer Sep 1, 2026
a6b0386
fix "no dependencies"
Mopsgamer Sep 1, 2026
206221d
fix the test
Mopsgamer Sep 1, 2026
1124dea
clickable keywords
Mopsgamer Sep 2, 2026
2b5cd62
minor insights improvements
Mopsgamer Sep 2, 2026
0a734cf
apply suggestions, with checkboxes for visibility
Mopsgamer Sep 2, 2026
1aea80e
fix: "no dependencies"
Mopsgamer Sep 2, 2026
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
2 changes: 1 addition & 1 deletion app/components/BaseCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ defineProps<{

<template>
<article
class="group bg-bg-subtle border border-border rounded-lg p-4 sm:p-6 transition-[border-color,background-color] duration-200 hover:(border-border-hover bg-bg-muted) cursor-pointer relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50 focus-within:bg-bg-muted focus-within:border-border-hover"
class="group bg-bg-subtle border border-border rounded-lg p-4 sm:p-6 transition-[border-color,background-color] duration-200 hover:(border-border-hover bg-bg-muted) relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50 focus-within:bg-bg-muted focus-within:border-border-hover"
:class="{
'border-accent/30 contrast-more:border-accent/90 bg-accent/5': isExactMatch,
'bg-fg-subtle/15!': selected,
Expand Down
98 changes: 98 additions & 0 deletions app/components/Dependencies/Card.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<script setup lang="ts">
import type { PackageDependencyItem } from '#shared/types/package-dependencies'
import type { PackageDependencyInsights } from '~/composables/usePackageDependencyInsights'
import type { StructuredFilters } from '#shared/types/preferences'
import { DEFAULT_FILTERS } from '#shared/types/preferences'

const props = defineProps<{
insights?: PackageDependencyInsights
item: PackageDependencyItem
showSkeleton: boolean
index?: number
filters?: StructuredFilters
}>()

const { model: globalSearchQuery } = useGlobalSearch()

const activeFilters = computed<StructuredFilters>(() => {
if (props.filters) return props.filters
const parsed = parseSearchOperators(globalSearchQuery.value)
return {
...DEFAULT_FILTERS,
keywords: parsed.keywords ?? [],
text: parsed.text ?? '',
}
})

const targetName = computed(() => props.item.packageName || props.item.name)

// Fetch rich package metadata from API (with hydrated useState caching)
const { data: meta } = usePackageMeta(targetName)

const searchResult = computed(() => {
if (!meta.value) return null
const result = metaToSearchResult(meta.value)
result.package.name = props.item.name
result.package.version = props.item.range
return result
})

const packageUrl = computed(() => packageRoute(targetName.value))

const emit = defineEmits<{
clickKeyword: [keyword: string]
}>()
</script>

<template>
<BaseCard v-if="!searchResult || showSkeleton">
<header class="mb-4 flex items-baseline justify-between gap-2">
<h2
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 inline-flex items-center gap-2"
>
<NuxtLink
:to="packageUrl"
class="decoration-none hover:text-accent-fallback after:content-[''] after:absolute after:inset-0 inline-flex items-center gap-2 min-w-0"
:data-result-index="index"
dir="ltr"
>
<span class="i-simple-icons:npm w-3.5 h-3.5 shrink-0" aria-hidden="true" />
<span class="truncate">{{ item.name }}</span>
</NuxtLink>
<DependenciesStatusIndicators
:name="targetName"
:flags="item.flags"
class="relative z-10"
/>
</h2>
</header>
<SkeletonBlock class="h-5 w-full mb-2 sm:mb-3" />
<div class="flex flex-wrap items-center gap-x-3 sm:gap-x-4 gap-y-2 text-xs text-fg-muted">
<div class="flex items-center gap-1.5 min-w-0 relative z-10">
<dl>
<dt class="sr-only">{{ $t('package.card.version') }}</dt>
<dd class="font-mono truncate max-w-32" :title="item.range">{{ item.range }}</dd>
</dl>
</div>
<SkeletonBlock class="h-4 w-8ch" />
<SkeletonBlock class="h-4 w-3ch" />
<SkeletonBlock class="h-4 w-20ch sm:ms-auto" />
</div>
</BaseCard>

<PackageCard
v-else
heading-level="h2"
:result="searchResult"
:index="index"
:insights="insights || undefined"
:filters="activeFilters"
:to="packageUrl"
version-is-range
@click-keyword="emit('clickKeyword', $event)"
>
<template #status-indicators="{ insights }">
<DependenciesStatusIndicators :name="targetName" :flags="item.flags" v-bind="{ insights }" />
</template>
</PackageCard>
</template>
279 changes: 279 additions & 0 deletions app/components/Dependencies/InsightsSummary.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
<script setup lang="ts">
import type { PackageDependencySection } from '#shared/types/package-dependencies'
import type { PackageDependencyInsights } from '~/composables/usePackageDependencyInsights'
import { getVulnerableDepInfo, getDeprecatedDepInfo } from '~/utils/npm/problematic-dependencies'

const selectedInsights = defineModel<string[]>('selectedInsights', { default: () => [] })

function toggleInsight(id: string) {
if (selectedInsights.value.includes(id)) {
selectedInsights.value = selectedInsights.value.filter(i => i !== id)
} else {
selectedInsights.value = [...selectedInsights.value, id]
}
}

const props = withDefaults(
defineProps<{
insights?: PackageDependencyInsights
sections: PackageDependencySection[]
activeSections?: string[]
showSkeleton: boolean
packageName?: string
interactive?: boolean
}>(),
{
interactive: true,
},
)

const stats = computed(() => {
const urgent = { major: 0, minor: 0, patch: 0, vulnerable: 0, deprecated: 0, replacement: 0 }
const nonUrgent = { major: 0, minor: 0, patch: 0, vulnerable: 0, deprecated: 0, replacement: 0 }
const active = { major: 0, minor: 0, patch: 0, vulnerable: 0, deprecated: 0, replacement: 0 }

if (!props.insights) return { urgent, nonUrgent, active }

for (const section of props.sections) {
const isUrgent = ['dependencies', 'bundledDependencies'].includes(section.id)
const isActive = !props.activeSections || props.activeSections.includes(section.id)
const target = isUrgent ? urgent : nonUrgent

for (const item of section.items) {
const outdated = props.insights.outdatedDeps.value[item.name]
if (outdated) {
if (outdated.majorsBehind > 0) {
target.major++
if (isActive) active.major++
} else if (outdated.minorsBehind > 0) {
target.minor++
if (isActive) active.minor++
} else if (outdated.resolved !== outdated.latest) {
target.patch++
if (isActive) active.patch++
}
}

if (props.insights.replacementDeps.value[item.name]) {
target.replacement++
if (isActive) active.replacement++
}
const realName = item.packageName || item.name
if (getVulnerableDepInfo(realName, props.insights.vulnTree.value)) {
target.vulnerable++
if (isActive) active.vulnerable++
}
if (getDeprecatedDepInfo(realName, props.insights.vulnTree.value)) {
target.deprecated++
if (isActive) active.deprecated++
}
}
}

return { urgent, nonUrgent, active }
})

const vulnLoading = computed(
() =>
!props.insights ||
props.insights.vulnStatus?.value === 'idle' ||
props.insights.vulnStatus?.value === 'pending',
)
const outdatedLoading = computed(
() =>
!props.insights ||
props.insights.outdatedStatus?.value === 'idle' ||
props.insights.outdatedStatus?.value === 'pending',
)
const replacementLoading = computed(
() =>
!props.insights ||
props.insights.replacementStatus?.value === 'idle' ||
props.insights.replacementStatus?.value === 'pending',
)

interface InsightMetric {
id: string
label: string
icon: string
iconColor: string
loading: boolean
urgentCount: number
nonUrgentCount: number
totalCount: number
activeCount: number
}

const metrics = computed<InsightMetric[]>(() => [
{
id: 'major',
label: $t('package.dependencies.insights.major_behind'),
icon: 'i-lucide:arrow-up',
iconColor: 'text-red-700 dark:text-red-500',
loading: outdatedLoading.value,
urgentCount: stats.value.urgent.major,
nonUrgentCount: stats.value.nonUrgent.major,
totalCount: stats.value.urgent.major + stats.value.nonUrgent.major,
activeCount: stats.value.active.major,
},
{
id: 'minor',
label: $t('package.dependencies.insights.minor_behind'),
icon: 'i-lucide:arrow-up',
iconColor: 'text-orange-700 dark:text-orange-500',
loading: outdatedLoading.value,
urgentCount: stats.value.urgent.minor,
nonUrgentCount: stats.value.nonUrgent.minor,
totalCount: stats.value.urgent.minor + stats.value.nonUrgent.minor,
activeCount: stats.value.active.minor,
},
{
id: 'patch',
label: $t('package.dependencies.insights.patch_behind'),
icon: 'i-lucide:arrow-up',
iconColor: 'text-yellow-700 dark:text-yellow-500',
loading: outdatedLoading.value,
urgentCount: stats.value.urgent.patch,
nonUrgentCount: stats.value.nonUrgent.patch,
totalCount: stats.value.urgent.patch + stats.value.nonUrgent.patch,
activeCount: stats.value.active.patch,
},
{
id: 'vulnerable',
label: $t('package.dependencies.insights.vulnerable'),
icon: 'i-lucide:shield-alert',
iconColor: 'text-red-600',
loading: vulnLoading.value,
urgentCount: stats.value.urgent.vulnerable,
nonUrgentCount: stats.value.nonUrgent.vulnerable,
totalCount: stats.value.urgent.vulnerable + stats.value.nonUrgent.vulnerable,
activeCount: stats.value.active.vulnerable,
},
{
id: 'deprecated',
label: $t('package.dependencies.insights.deprecated'),
icon: 'i-lucide:octagon-alert',
iconColor: 'text-purple-700 dark:text-purple-500',
loading: vulnLoading.value,
urgentCount: stats.value.urgent.deprecated,
nonUrgentCount: stats.value.nonUrgent.deprecated,
totalCount: stats.value.urgent.deprecated + stats.value.nonUrgent.deprecated,
activeCount: stats.value.active.deprecated,
},
{
id: 'replacement',
label: $t('package.dependencies.insights.replacements_available'),
icon: 'i-lucide:lightbulb',
iconColor: 'text-amber-700 dark:text-amber-500',
loading: replacementLoading.value,
urgentCount: stats.value.urgent.replacement,
nonUrgentCount: stats.value.nonUrgent.replacement,
totalCount: stats.value.urgent.replacement + stats.value.nonUrgent.replacement,
activeCount: stats.value.active.replacement,
},
])

function isItemInteractive(item: InsightMetric): boolean {
return props.interactive && item.totalCount > 0
}

const tooltipText = computed(() => {
return `${$t('package.dependencies.insights.subtitle')} — ${$t('package.dependencies.insights.tooltip_urgent')} / ${$t('package.dependencies.insights.tooltip_other')}`
})
</script>

<template>
<section id="dependency-health" class="w-full py-4 mt-6">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-1.5">
<h2 class="text-fg-muted uppercase">
{{ $t('package.dependencies.insights.title') }}
</h2>
<TooltipApp :text="tooltipText" position="bottom">
<span class="i-lucide:info w-3.5 h-3.5" aria-hidden="true" />
</TooltipApp>
</div>

<button
v-if="interactive && selectedInsights.length > 0"
type="button"
class="inline-flex items-center gap-1 text-xs text-fg-subtle hover:text-fg font-mono transition-colors duration-200 cursor-pointer"
@click="selectedInsights = []"
>
<span class="i-lucide:x w-3 h-3" aria-hidden="true" />
<span>{{ $t('filters.clear_all') }}</span>
</button>
</div>

<div
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-y-3 gap-x-4 border-y-border border-y py-3"
>
<component
:is="isItemInteractive(item) ? 'button' : 'div'"
v-for="item in metrics"
:key="item.id"
:type="isItemInteractive(item) ? 'button' : undefined"
class="py-1.5 px-2 rounded-md text-start transition-colors duration-200"
:class="[
isItemInteractive(item)
? 'focus-visible:(outline-2 outline-accent/70 outline-offset-1) hover:bg-bg-subtle cursor-pointer'
: '',
isItemInteractive(item) && selectedInsights.includes(item.id)
? 'bg-bg-muted ring-1 ring-border-hover'
: '',
]"
:aria-pressed="isItemInteractive(item) ? selectedInsights.includes(item.id) : undefined"
:aria-label="
isItemInteractive(item)
? selectedInsights.includes(item.id)
? $t('package.dependencies.insights.clear_filter', { filter: item.label })
: $t('package.dependencies.insights.filter_by', { filter: item.label })
: undefined
"
@click="isItemInteractive(item) ? toggleInsight(item.id) : undefined"
>
<span
class="text-xs text-fg-muted lowercase flex items-center justify-between gap-1.5 w-full min-w-0"
>
<span class="flex items-center gap-1.5 truncate min-w-0">
<input
v-if="interactive && item.totalCount > 0"
type="checkbox"
:checked="selectedInsights.includes(item.id)"
tabindex="-1"
aria-hidden="true"
class="w-3.5 h-3.5 accent-fg bg-bg-muted border-border rounded cursor-pointer shrink-0 pointer-events-none"
/>
<span class="truncate">{{ item.label }}</span>
</span>
<span :class="[item.icon, item.iconColor, 'w-3.5 h-3.5 shrink-0']" aria-hidden="true" />
</span>
<span class="text-sm font-mono mt-1 block">
<template v-if="showSkeleton || item.loading">
<span class="inline-flex items-center gap-1 text-fg-subtle">
<span class="i-svg-spinners:ring-resize w-3 h-3" aria-hidden="true" />
</span>
</template>
<template v-else>
<span class="flex text-sm items-center text-fg-subtle gap-1">
<span
v-if="item.totalCount === 0"
class="i-lucide:check w-3 h-3"
aria-hidden="true"
/>
<span class="tabular-nums" :class="item.urgentCount > 0 ? 'text-fg font-medium' : ''">
{{ item.urgentCount }}
</span>
<template v-if="item.totalCount > 0">
<span class="px-0.5">/</span>
<span class="tabular-nums text-fg-muted">
{{ item.totalCount }}
</span>
</template>
</span>
</template>
</span>
</component>
</div>
</section>
</template>
Loading
Loading