Skip to content

Commit 3d78d8f

Browse files
committed
Merge branch 'main' of https://github.com/npmx-dev/npmx.dev into fix/locale-persistence
2 parents 43fb5ef + 73e50ef commit 3d78d8f

42 files changed

Lines changed: 4568 additions & 84 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTRIBUTING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ The connector will check your npm authentication, generate a connection token, a
105105

106106
## Code style
107107

108+
When committing changes, try to keep an eye out for unintended formatting updates. These can make a pull request look noisier than it really is and slow down the review process. Sometimes IDEs automatically reformat files on save, which can unintentionally introduce extra changes.
109+
110+
To help with this, the project uses `oxfmt` to handle formatting via a pre-commit hook. The hook will automatically reformat files when needed. If something can’t be fixed automatically, it will let you know what needs to be updated before you can commit.
111+
112+
If you want to get ahead of any formatting issues, you can also run `pnpm lint:fix` before committing to fix formatting across the whole project.
113+
108114
### Typescript
109115

110116
- We care about good types – never cast things to `any` 💪

app/components/AppHeader.vue

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ function expandMobileSearch() {
3030
})
3131
}
3232
33+
watch(
34+
isOnSearchPage,
35+
visible => {
36+
if (!visible) return
37+
38+
searchBoxRef.value?.focus()
39+
nextTick(() => {
40+
searchBoxRef.value?.focus()
41+
})
42+
},
43+
{ flush: 'sync' },
44+
)
45+
3346
function handleSearchBlur() {
3447
showFullSearch.value = false
3548
// Collapse expanded search on mobile after blur (with delay for click handling)
@@ -140,6 +153,15 @@ onKeyStroke(
140153

141154
<!-- End: Desktop nav items + Mobile menu button -->
142155
<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+
143165
<!-- Desktop: Settings link -->
144166
<NuxtLink
145167
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"

app/components/PackageSkeleton.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@
150150
</div>
151151

152152
<!-- Sidebar: order-1 lg:order-2 space-y-8 -->
153-
<aside class="order-1 lg:order-2 space-y-8">
153+
<div class="order-1 lg:order-2 space-y-8">
154154
<!-- Maintainers -->
155155
<section>
156156
<h2
@@ -249,7 +249,7 @@
249249
</li>
250250
</ul>
251251
</section>
252-
</aside>
252+
</div>
253253
</div>
254254
</article>
255255
</template>

app/components/SearchBox.vue

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
<script setup lang="ts">
22
import { debounce } from 'perfect-debounce'
33
4-
const isMobile = useIsMobile()
5-
64
withDefaults(
75
defineProps<{
86
inputClass?: string
@@ -107,7 +105,6 @@ defineExpose({ focus })
107105
<input
108106
id="header-search"
109107
ref="inputRef"
110-
:autofocus="!isMobile"
111108
v-model="searchQuery"
112109
type="search"
113110
name="q"
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>

0 commit comments

Comments
 (0)