Skip to content

Commit 3c54fb7

Browse files
committed
fix: add mobile layout + menu item
1 parent 4d0f0ac commit 3c54fb7

5 files changed

Lines changed: 263 additions & 91 deletions

File tree

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: 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>

app/components/compare/PackageSelector.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ function handleBlur() {
9090
class="absolute inset-is-3 top-1/2 -translate-y-1/2 text-fg-subtle"
9191
aria-hidden="true"
9292
>
93-
<span class="i-carbon:search w-4 h-4" />
93+
<span class="i-carbon:search inline-block w-4 h-4" />
9494
</span>
9595
<input
9696
id="package-search"

app/pages/compare.vue

Lines changed: 111 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -59,95 +59,118 @@ useSeoMeta({
5959
</script>
6060

6161
<template>
62-
<main class="container py-8 xl:py-12">
63-
<header class="mb-8">
64-
<h1 class="font-mono text-2xl sm:text-3xl font-medium mb-2">
65-
{{ $t('compare.packages.title') }}
66-
</h1>
67-
<p class="text-fg-muted">
68-
{{ $t('compare.packages.tagline') }}
69-
</p>
70-
</header>
71-
72-
<!-- Package selector -->
73-
<section class="mb-8" aria-labelledby="packages-heading">
74-
<h2 id="packages-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3">
75-
{{ $t('compare.packages.section_packages') }}
76-
</h2>
77-
<ComparePackageSelector v-model="packages" :max="4" />
78-
</section>
79-
80-
<!-- Facet selector -->
81-
<section class="mb-8" aria-labelledby="facets-heading">
82-
<div class="flex items-center gap-2 mb-3">
83-
<h2 id="facets-heading" class="text-xs text-fg-subtle uppercase tracking-wider">
84-
{{ $t('compare.packages.section_facets') }}
62+
<main class="container flex-1 py-12 sm:py-16 w-full">
63+
<div class="max-w-2xl mx-auto">
64+
<header class="mb-12">
65+
<h1 class="font-mono text-3xl sm:text-4xl font-medium mb-4">
66+
{{ $t('compare.packages.title') }}
67+
</h1>
68+
<p class="text-fg-muted text-lg">
69+
{{ $t('compare.packages.tagline') }}
70+
</p>
71+
</header>
72+
73+
<!-- Package selector -->
74+
<section class="mb-8" aria-labelledby="packages-heading">
75+
<h2 id="packages-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3">
76+
{{ $t('compare.packages.section_packages') }}
8577
</h2>
86-
<button
87-
type="button"
88-
class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline"
89-
:class="isAllSelected ? 'text-fg-muted' : 'text-fg-muted/60 hover:text-fg-muted'"
90-
:disabled="isAllSelected"
91-
:aria-label="$t('compare.facets.select_all')"
92-
@click="selectAll"
93-
>
94-
{{ $t('compare.facets.all') }}
95-
</button>
96-
<span class="text-[10px] text-fg-muted/40" aria-hidden="true">/</span>
97-
<button
98-
type="button"
99-
class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline"
100-
:class="isNoneSelected ? 'text-fg-muted' : 'text-fg-muted/60 hover:text-fg-muted'"
101-
:disabled="isNoneSelected"
102-
:aria-label="$t('compare.facets.deselect_all')"
103-
@click="deselectAll"
78+
<ComparePackageSelector v-model="packages" :max="4" />
79+
</section>
80+
81+
<!-- Facet selector -->
82+
<section class="mb-8" aria-labelledby="facets-heading">
83+
<div class="flex items-center gap-2 mb-3">
84+
<h2 id="facets-heading" class="text-xs text-fg-subtle uppercase tracking-wider">
85+
{{ $t('compare.packages.section_facets') }}
86+
</h2>
87+
<button
88+
type="button"
89+
class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline"
90+
:class="isAllSelected ? 'text-fg-muted' : 'text-fg-muted/60 hover:text-fg-muted'"
91+
:disabled="isAllSelected"
92+
:aria-label="$t('compare.facets.select_all')"
93+
@click="selectAll"
94+
>
95+
{{ $t('compare.facets.all') }}
96+
</button>
97+
<span class="text-[10px] text-fg-muted/40" aria-hidden="true">/</span>
98+
<button
99+
type="button"
100+
class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline"
101+
:class="isNoneSelected ? 'text-fg-muted' : 'text-fg-muted/60 hover:text-fg-muted'"
102+
:disabled="isNoneSelected"
103+
:aria-label="$t('compare.facets.deselect_all')"
104+
@click="deselectAll"
105+
>
106+
{{ $t('compare.facets.none') }}
107+
</button>
108+
</div>
109+
<CompareFacetSelector />
110+
</section>
111+
112+
<!-- Comparison grid -->
113+
<section v-if="canCompare" class="mt-10" aria-labelledby="comparison-heading">
114+
<h2 id="comparison-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-4">
115+
{{ $t('compare.packages.section_comparison') }}
116+
</h2>
117+
118+
<div
119+
v-if="status === 'pending' && (!packagesData || packagesData.every(p => p === null))"
120+
class="flex items-center justify-center py-12"
104121
>
105-
{{ $t('compare.facets.none') }}
106-
</button>
107-
</div>
108-
<CompareFacetSelector />
109-
</section>
110-
111-
<!-- Comparison grid -->
112-
<section v-if="canCompare" class="mt-10" aria-labelledby="comparison-heading">
113-
<h2 id="comparison-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-4">
114-
{{ $t('compare.packages.section_comparison') }}
115-
</h2>
116-
117-
<div
118-
v-if="status === 'pending' && (!packagesData || packagesData.every(p => p === null))"
119-
class="flex items-center justify-center py-12"
120-
>
121-
<LoadingSpinner :text="$t('compare.packages.loading')" />
122-
</div>
123-
124-
<div v-else-if="packagesData && packagesData.some(p => p !== null)">
125-
<CompareComparisonGrid :columns="packages.length" :headers="gridHeaders">
126-
<CompareFacetRow
127-
v-for="facet in selectedFacets"
128-
:key="facet"
129-
:label="FACET_INFO[facet].label"
130-
:description="FACET_INFO[facet].description"
131-
:values="getFacetValues(facet)"
132-
:facet-loading="isFacetLoading(facet)"
133-
:column-loading="columnLoading"
134-
:bar="facet !== 'lastUpdated'"
135-
/>
136-
</CompareComparisonGrid>
137-
</div>
138-
139-
<div v-else class="text-center py-12" role="alert">
140-
<p class="text-fg-muted">{{ $t('compare.packages.error') }}</p>
141-
</div>
142-
</section>
143-
144-
<!-- Empty state -->
145-
<section v-else class="text-center py-16 border border-dashed border-border rounded-lg">
146-
<div class="i-carbon:compare w-12 h-12 text-fg-subtle mx-auto mb-4" aria-hidden="true" />
147-
<h2 class="font-mono text-lg text-fg-muted mb-2">{{ $t('compare.packages.empty_title') }}</h2>
148-
<p class="text-sm text-fg-subtle max-w-md mx-auto">
149-
{{ $t('compare.packages.empty_description') }}
150-
</p>
151-
</section>
122+
<LoadingSpinner :text="$t('compare.packages.loading')" />
123+
</div>
124+
125+
<div v-else-if="packagesData && packagesData.some(p => p !== null)">
126+
<!-- Desktop: Grid layout -->
127+
<div class="hidden md:block overflow-x-auto">
128+
<CompareComparisonGrid :columns="packages.length" :headers="gridHeaders">
129+
<CompareFacetRow
130+
v-for="facet in selectedFacets"
131+
:key="facet"
132+
:label="FACET_INFO[facet].label"
133+
:description="FACET_INFO[facet].description"
134+
:values="getFacetValues(facet)"
135+
:facet-loading="isFacetLoading(facet)"
136+
:column-loading="columnLoading"
137+
:bar="facet !== 'lastUpdated'"
138+
:headers="gridHeaders"
139+
/>
140+
</CompareComparisonGrid>
141+
</div>
142+
143+
<!-- Mobile: Card-based layout -->
144+
<div class="md:hidden space-y-3">
145+
<CompareFacetCard
146+
v-for="facet in selectedFacets"
147+
:key="facet"
148+
:label="FACET_INFO[facet].label"
149+
:description="FACET_INFO[facet].description"
150+
:values="getFacetValues(facet)"
151+
:facet-loading="isFacetLoading(facet)"
152+
:column-loading="columnLoading"
153+
:bar="facet !== 'lastUpdated'"
154+
:headers="gridHeaders"
155+
/>
156+
</div>
157+
</div>
158+
159+
<div v-else class="text-center py-12" role="alert">
160+
<p class="text-fg-muted">{{ $t('compare.packages.error') }}</p>
161+
</div>
162+
</section>
163+
164+
<!-- Empty state -->
165+
<section v-else class="text-center py-16 border border-dashed border-border rounded-lg">
166+
<div class="i-carbon:compare w-12 h-12 text-fg-subtle mx-auto mb-4" aria-hidden="true" />
167+
<h2 class="font-mono text-lg text-fg-muted mb-2">
168+
{{ $t('compare.packages.empty_title') }}
169+
</h2>
170+
<p class="text-sm text-fg-subtle max-w-md mx-auto">
171+
{{ $t('compare.packages.empty_description') }}
172+
</p>
173+
</section>
174+
</div>
152175
</main>
153176
</template>

i18n/locales/en.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -794,8 +794,8 @@
794794
},
795795
"compare": {
796796
"packages": {
797-
"title": "Compare Packages",
798-
"tagline": "Compare npm packages side-by-side to help you choose the right one.",
797+
"title": "compare packages",
798+
"tagline": "compare npm packages side-by-side to help you choose the right one.",
799799
"meta_title": "Compare {packages} - npmx",
800800
"meta_title_empty": "Compare Packages - npmx",
801801
"meta_description": "Side-by-side comparison of {packages}",

0 commit comments

Comments
 (0)