Skip to content

Commit 73e50ef

Browse files
serhalpdanielroe
andauthored
feat: add package comparison feature (#383)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent f8f4373 commit 73e50ef

26 files changed

Lines changed: 2917 additions & 9 deletions

app/components/AppHeader.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,15 @@ onKeyStroke(
153153

154154
<!-- End: Desktop nav items + Mobile menu button -->
155155
<div class="flex-shrink-0 flex items-center gap-4 sm:gap-6">
156+
<!-- Desktop: Compare link -->
157+
<NuxtLink
158+
to="/compare"
159+
class="hidden sm:inline-flex link-subtle font-mono text-sm items-center gap-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"
160+
>
161+
<span class="i-carbon:compare w-4 h-4" aria-hidden="true" />
162+
{{ $t('nav.compare') }}
163+
</NuxtLink>
164+
156165
<!-- Desktop: Settings link -->
157166
<NuxtLink
158167
to="/settings"

app/components/CollapsibleSection.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { ref, computed } from 'vue'
2+
import { shallowRef, computed } from 'vue'
33
44
interface Props {
55
title: string
@@ -19,7 +19,7 @@ const buttonId = `${props.id}-collapsible-button`
1919
const contentId = `${props.id}-collapsible-content`
2020
const headingId = `${props.id}-heading`
2121
22-
const isOpen = ref(true)
22+
const isOpen = shallowRef(true)
2323
2424
onPrehydrate(() => {
2525
const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')

app/components/MobileMenu.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,15 @@ watch(isOpen, open => (isLocked.value = open))
9999
{{ $t('footer.about') }}
100100
</NuxtLink>
101101

102+
<NuxtLink
103+
to="/compare"
104+
class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200"
105+
@click="closeMenu"
106+
>
107+
<span class="i-carbon:compare w-5 h-5 text-fg-muted" aria-hidden="true" />
108+
{{ $t('nav.compare') }}
109+
</NuxtLink>
110+
102111
<NuxtLink
103112
to="/settings"
104113
class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200"
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<script setup lang="ts">
2+
defineProps<{
3+
/** Number of columns (2-4) */
4+
columns: number
5+
/** Column headers (package names or version numbers) */
6+
headers: string[]
7+
}>()
8+
</script>
9+
10+
<template>
11+
<div class="overflow-x-auto">
12+
<div
13+
class="comparison-grid"
14+
:class="[columns === 4 ? 'min-w-[800px]' : 'min-w-[600px]', `columns-${columns}`]"
15+
:style="{ '--columns': columns }"
16+
>
17+
<!-- Header row -->
18+
<div class="comparison-header">
19+
<div class="comparison-label" />
20+
<div
21+
v-for="(header, index) in headers"
22+
:key="index"
23+
class="comparison-cell comparison-cell-header"
24+
>
25+
<span class="font-mono text-sm font-medium text-fg truncate" :title="header">
26+
{{ header }}
27+
</span>
28+
</div>
29+
</div>
30+
31+
<!-- Facet rows -->
32+
<slot />
33+
</div>
34+
</div>
35+
</template>
36+
37+
<style scoped>
38+
.comparison-grid {
39+
display: grid;
40+
gap: 0;
41+
}
42+
43+
.comparison-grid.columns-2 {
44+
grid-template-columns: minmax(120px, 180px) repeat(2, 1fr);
45+
}
46+
47+
.comparison-grid.columns-3 {
48+
grid-template-columns: minmax(120px, 160px) repeat(3, 1fr);
49+
}
50+
51+
.comparison-grid.columns-4 {
52+
grid-template-columns: minmax(100px, 140px) repeat(4, 1fr);
53+
}
54+
55+
.comparison-header {
56+
display: contents;
57+
}
58+
59+
.comparison-header > .comparison-label {
60+
padding: 0.75rem 1rem;
61+
border-bottom: 1px solid var(--color-border);
62+
}
63+
64+
.comparison-header > .comparison-cell-header {
65+
padding: 0.75rem 1rem;
66+
background: var(--color-bg-subtle);
67+
border-bottom: 1px solid var(--color-border);
68+
text-align: center;
69+
}
70+
71+
/* First header cell rounded top-start */
72+
.comparison-header > .comparison-cell-header:first-of-type {
73+
border-start-start-radius: 0.5rem;
74+
}
75+
76+
/* Last header cell rounded top-end */
77+
.comparison-header > .comparison-cell-header:last-of-type {
78+
border-start-end-radius: 0.5rem;
79+
}
80+
</style>
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<script setup lang="ts">
2+
import type { FacetValue } from '#shared/types'
3+
4+
const props = defineProps<{
5+
/** Facet label */
6+
label: string
7+
/** Description/tooltip for the facet */
8+
description?: string
9+
/** Values for each column */
10+
values: (FacetValue | null | undefined)[]
11+
/** Whether this facet is loading (e.g., install size) */
12+
facetLoading?: boolean
13+
/** Whether each column is loading (array matching values) */
14+
columnLoading?: boolean[]
15+
/** Whether to show the proportional bar (defaults to true for numeric values) */
16+
bar?: boolean
17+
/** Package headers for display */
18+
headers: string[]
19+
}>()
20+
21+
// Check if all values are numeric (for bar visualization)
22+
const isNumeric = computed(() => {
23+
return props.values.every(v => v === null || v === undefined || typeof v.raw === 'number')
24+
})
25+
26+
// Show bar if explicitly enabled, or if not specified and values are numeric
27+
const showBar = computed(() => {
28+
return props.bar ?? isNumeric.value
29+
})
30+
31+
// Get max value for bar width calculation
32+
const maxValue = computed(() => {
33+
if (!isNumeric.value) return 0
34+
return Math.max(...props.values.map(v => (typeof v?.raw === 'number' ? v.raw : 0)))
35+
})
36+
37+
// Calculate bar width percentage for a value
38+
function getBarWidth(value: FacetValue | null | undefined): number {
39+
if (!isNumeric.value || !maxValue.value || !value || typeof value.raw !== 'number') return 0
40+
return (value.raw / maxValue.value) * 100
41+
}
42+
43+
function getStatusClass(status?: FacetValue['status']): string {
44+
switch (status) {
45+
case 'good':
46+
return 'text-emerald-400'
47+
case 'info':
48+
return 'text-blue-400'
49+
case 'warning':
50+
return 'text-amber-400'
51+
case 'bad':
52+
return 'text-red-400'
53+
default:
54+
return 'text-fg'
55+
}
56+
}
57+
58+
// Check if a specific cell is loading
59+
function isCellLoading(index: number): boolean {
60+
return props.facetLoading || (props.columnLoading?.[index] ?? false)
61+
}
62+
63+
// Get short package name (without version) for mobile display
64+
function getShortName(header: string): string {
65+
const atIndex = header.lastIndexOf('@')
66+
if (atIndex > 0) {
67+
return header.substring(0, atIndex)
68+
}
69+
return header
70+
}
71+
</script>
72+
73+
<template>
74+
<div class="border border-border rounded-lg overflow-hidden">
75+
<!-- Facet header -->
76+
<div class="flex items-center gap-1.5 px-3 py-2 bg-bg-subtle border-b border-border">
77+
<span class="text-xs text-fg-muted uppercase tracking-wider font-medium">{{ label }}</span>
78+
<span
79+
v-if="description"
80+
class="i-carbon:information w-3 h-3 text-fg-subtle"
81+
:title="description"
82+
aria-hidden="true"
83+
/>
84+
</div>
85+
86+
<!-- Package values -->
87+
<div class="divide-y divide-border">
88+
<div
89+
v-for="(value, index) in values"
90+
:key="index"
91+
class="relative flex items-center justify-between gap-2 px-3 py-2"
92+
>
93+
<!-- Background bar for numeric values -->
94+
<div
95+
v-if="showBar && value && getBarWidth(value) > 0"
96+
class="absolute inset-y-0 inset-is-0 bg-fg/5 transition-all duration-300"
97+
:style="{ width: `${getBarWidth(value)}%` }"
98+
aria-hidden="true"
99+
/>
100+
101+
<!-- Package name -->
102+
<span
103+
class="relative font-mono text-xs text-fg-muted truncate flex-shrink min-w-0"
104+
:title="headers[index]"
105+
>
106+
{{ getShortName(headers[index] ?? '') }}
107+
</span>
108+
109+
<!-- Value -->
110+
<span class="relative flex-shrink-0">
111+
<!-- Loading state -->
112+
<template v-if="isCellLoading(index)">
113+
<span
114+
class="i-carbon:circle-dash w-4 h-4 text-fg-subtle motion-safe:animate-spin"
115+
aria-hidden="true"
116+
/>
117+
</template>
118+
119+
<!-- No data -->
120+
<template v-else-if="!value">
121+
<span class="text-fg-subtle text-sm">-</span>
122+
</template>
123+
124+
<!-- Value display -->
125+
<template v-else>
126+
<span class="font-mono text-sm tabular-nums" :class="getStatusClass(value.status)">
127+
<!-- Date values use DateTime component for i18n and user settings -->
128+
<DateTime
129+
v-if="value.type === 'date'"
130+
:datetime="value.display"
131+
date-style="medium"
132+
/>
133+
<template v-else>{{ value.display }}</template>
134+
</span>
135+
</template>
136+
</span>
137+
</div>
138+
</div>
139+
</div>
140+
</template>
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<script setup lang="ts">
2+
import type { FacetValue } from '#shared/types'
3+
4+
const props = defineProps<{
5+
/** Facet label */
6+
label: string
7+
/** Description/tooltip for the facet */
8+
description?: string
9+
/** Values for each column */
10+
values: (FacetValue | null | undefined)[]
11+
/** Whether this facet is loading (e.g., install size) */
12+
facetLoading?: boolean
13+
/** Whether each column is loading (array matching values) */
14+
columnLoading?: boolean[]
15+
/** Whether to show the proportional bar (defaults to true for numeric values) */
16+
bar?: boolean
17+
}>()
18+
19+
// Check if all values are numeric (for bar visualization)
20+
const isNumeric = computed(() => {
21+
return props.values.every(v => v === null || v === undefined || typeof v.raw === 'number')
22+
})
23+
24+
// Show bar if explicitly enabled, or if not specified and values are numeric
25+
const showBar = computed(() => {
26+
return props.bar ?? isNumeric.value
27+
})
28+
29+
// Get max value for bar width calculation
30+
const maxValue = computed(() => {
31+
if (!isNumeric.value) return 0
32+
return Math.max(...props.values.map(v => (typeof v?.raw === 'number' ? v.raw : 0)))
33+
})
34+
35+
// Calculate bar width percentage for a value
36+
function getBarWidth(value: FacetValue | null | undefined): number {
37+
if (!isNumeric.value || !maxValue.value || !value || typeof value.raw !== 'number') return 0
38+
return (value.raw / maxValue.value) * 100
39+
}
40+
41+
function getStatusClass(status?: FacetValue['status']): string {
42+
switch (status) {
43+
case 'good':
44+
return 'text-emerald-400'
45+
case 'info':
46+
return 'text-blue-400'
47+
case 'warning':
48+
return 'text-amber-400'
49+
case 'bad':
50+
return 'text-red-400'
51+
default:
52+
return 'text-fg'
53+
}
54+
}
55+
56+
// Check if a specific cell is loading
57+
function isCellLoading(index: number): boolean {
58+
return props.facetLoading || (props.columnLoading?.[index] ?? false)
59+
}
60+
</script>
61+
62+
<template>
63+
<div class="contents">
64+
<!-- Label cell -->
65+
<div
66+
class="comparison-label flex items-center gap-1.5 px-4 py-3 border-b border-border"
67+
:title="description"
68+
>
69+
<span class="text-xs text-fg-muted uppercase tracking-wider">{{ label }}</span>
70+
<span
71+
v-if="description"
72+
class="i-carbon:information w-3 h-3 text-fg-subtle"
73+
aria-hidden="true"
74+
/>
75+
</div>
76+
77+
<!-- Value cells -->
78+
<div
79+
v-for="(value, index) in values"
80+
:key="index"
81+
class="comparison-cell relative flex items-end justify-center px-4 py-3 border-b border-border"
82+
>
83+
<!-- Background bar for numeric values -->
84+
<div
85+
v-if="showBar && value && getBarWidth(value) > 0"
86+
class="absolute inset-y-1 inset-is-1 bg-fg/5 rounded-sm transition-all duration-300"
87+
:style="{ width: `calc(${getBarWidth(value)}% - 8px)` }"
88+
aria-hidden="true"
89+
/>
90+
91+
<!-- Loading state -->
92+
<template v-if="isCellLoading(index)">
93+
<span
94+
class="i-carbon:circle-dash w-4 h-4 text-fg-subtle motion-safe:animate-spin"
95+
aria-hidden="true"
96+
/>
97+
</template>
98+
99+
<!-- No data -->
100+
<template v-else-if="!value">
101+
<span class="text-fg-subtle text-sm">-</span>
102+
</template>
103+
104+
<!-- Value display -->
105+
<template v-else>
106+
<span class="relative font-mono text-sm tabular-nums" :class="getStatusClass(value.status)">
107+
<!-- Date values use DateTime component for i18n and user settings -->
108+
<DateTime v-if="value.type === 'date'" :datetime="value.display" date-style="medium" />
109+
<template v-else>{{ value.display }}</template>
110+
</span>
111+
</template>
112+
</div>
113+
</div>
114+
</template>

0 commit comments

Comments
 (0)