Skip to content

Commit 3ed8a09

Browse files
committed
feat: infinite scroll on search results
1 parent da3d1ee commit 3ed8a09

10 files changed

Lines changed: 1107 additions & 389 deletions

File tree

app/app.vue

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
<script setup lang="ts">
2+
const route = useRoute()
3+
4+
const isHomepage = computed(() => route.path === '/')
5+
26
useHead({
37
titleTemplate: (titleChunk) => {
48
return titleChunk ? titleChunk : 'npmx - Better npm Package Browser'
@@ -19,12 +23,18 @@ useHead({
1923
class="container h-14 flex items-center justify-between"
2024
>
2125
<NuxtLink
26+
v-show="!isHomepage"
2227
to="/"
2328
aria-label="npmx home"
24-
class="font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded"
29+
class="header-logo font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded"
2530
>
26-
<span class="opacity-50">./</span>npmx
31+
<span class="text-fg-subtle"><span style="letter-spacing: -0.2em;">.</span>/</span>npmx
2732
</NuxtLink>
33+
<!-- Spacer when logo is hidden on homepage -->
34+
<span
35+
v-show="isHomepage"
36+
class="w-1"
37+
/>
2838

2939
<ul class="flex items-center gap-6">
3040
<li class="flex">
@@ -413,4 +423,35 @@ button {
413423
margin: 0 0.25rem 0.25rem 0;
414424
border-radius: 4px;
415425
}
426+
427+
/* Inline code in package descriptions */
428+
p > span > code,
429+
.line-clamp-2 code {
430+
font-family: 'JetBrains Mono', monospace;
431+
font-size: 0.85em;
432+
background: #1a1a1a;
433+
padding: 0.1em 0.3em;
434+
border-radius: 3px;
435+
border: 1px solid #262626;
436+
}
437+
438+
/* View transition for search box (includes / and input) */
439+
.search-box {
440+
view-transition-name: search-box;
441+
}
442+
443+
/* View transition for logo (hero -> header) */
444+
.hero-logo,
445+
.header-logo {
446+
view-transition-name: site-logo;
447+
}
448+
449+
/* Customize the view transition animations */
450+
::view-transition-old(search-box),
451+
::view-transition-new(search-box),
452+
::view-transition-old(site-logo),
453+
::view-transition-new(site-logo) {
454+
animation-duration: 0.3s;
455+
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
456+
}
416457
</style>

app/components/MarkdownText.vue

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
text: string
4+
}>()
5+
6+
// Escape HTML to prevent XSS
7+
function escapeHtml(text: string): string {
8+
return text
9+
.replace(/&/g, '&amp;')
10+
.replace(/</g, '&lt;')
11+
.replace(/>/g, '&gt;')
12+
.replace(/"/g, '&quot;')
13+
.replace(/'/g, '&#039;')
14+
}
15+
16+
// Parse simple inline markdown to HTML
17+
function parseMarkdown(text: string): string {
18+
if (!text) return ''
19+
20+
// First escape HTML
21+
let html = escapeHtml(text)
22+
23+
// Bold: **text** or __text__
24+
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
25+
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>')
26+
27+
// Italic: *text* or _text_
28+
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>')
29+
html = html.replace(/\b_(.+?)_\b/g, '<em>$1</em>')
30+
31+
// Inline code: `code`
32+
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
33+
34+
// Strikethrough: ~~text~~
35+
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>')
36+
37+
return html
38+
}
39+
40+
const html = computed(() => parseMarkdown(props.text))
41+
</script>
42+
43+
<template>
44+
<!-- eslint-disable-next-line vue/no-v-html -->
45+
<span v-html="html" />
46+
</template>

app/composables/useNpmRegistry.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ export function usePackageDownloads(
8989
)
9090
}
9191

92+
const emptySearchResponse = { objects: [], total: 0, time: new Date().toISOString() } satisfies NpmSearchResponse
93+
9294
export function useNpmSearch(
9395
query: MaybeRefOrGetter<string>,
9496
options: MaybeRefOrGetter<{
@@ -97,16 +99,19 @@ export function useNpmSearch(
9799
}> = {},
98100
) {
99101
const registry = useNpmRegistry()
102+
let lastSearch: NpmSearchResponse | undefined = undefined
100103

101104
return useAsyncData(
102-
`search:${toValue(query)}:${JSON.stringify(toValue(options))}`,
103-
() => {
105+
() => `search:${toValue(query)}:${JSON.stringify(toValue(options))}`,
106+
async () => {
104107
const q = toValue(query)
105108
if (!q.trim()) {
106-
return Promise.resolve({ objects: [], total: 0, time: new Date().toISOString() } as NpmSearchResponse)
109+
return Promise.resolve(emptySearchResponse)
107110
}
108-
return registry.searchPackages(q, toValue(options))
111+
return lastSearch = await registry.searchPackages(q, toValue(options))
112+
},
113+
{
114+
default: () => lastSearch || emptySearchResponse,
109115
},
110-
{ watch: [() => toValue(query), () => toValue(options)] },
111116
)
112117
}

app/error.vue

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<script setup lang="ts">
2+
import type { NuxtError } from '#app'
3+
4+
const props = defineProps<{
5+
error: NuxtError
6+
}>()
7+
8+
const statusCode = computed(() => props.error.statusCode || 500)
9+
const statusMessage = computed(() => {
10+
if (props.error.statusMessage) return props.error.statusMessage
11+
switch (statusCode.value) {
12+
case 404: return 'Page not found'
13+
case 500: return 'Internal server error'
14+
case 503: return 'Service unavailable'
15+
default: return 'Something went wrong'
16+
}
17+
})
18+
19+
function handleError() {
20+
clearError({ redirect: '/' })
21+
}
22+
23+
useHead({
24+
title: `${statusCode.value} - ${statusMessage.value}`,
25+
})
26+
</script>
27+
28+
<template>
29+
<div class="min-h-screen flex flex-col bg-bg text-fg">
30+
<header class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border">
31+
<nav
32+
aria-label="Main navigation"
33+
class="container h-14 flex items-center justify-between"
34+
>
35+
<a
36+
href="/"
37+
class="header-logo font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded"
38+
>
39+
<span class="text-fg-subtle">./</span>npmx
40+
</a>
41+
42+
<ul class="flex items-center gap-6 list-none m-0 p-0">
43+
<li class="flex">
44+
<a
45+
href="/search"
46+
class="link-subtle font-mono text-sm inline-flex items-center"
47+
>
48+
search
49+
</a>
50+
</li>
51+
<li class="flex">
52+
<a
53+
href="https://github.com/danielroe/npmx.dev"
54+
rel="noopener noreferrer"
55+
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
56+
>
57+
<span class="i-carbon-logo-github w-4 h-4" />
58+
<span class="hidden sm:inline">github</span>
59+
</a>
60+
</li>
61+
</ul>
62+
</nav>
63+
</header>
64+
65+
<main class="flex-1 container flex flex-col items-center justify-center py-20 text-center">
66+
<p class="font-mono text-8xl sm:text-9xl font-medium text-fg-subtle mb-4">
67+
{{ statusCode }}
68+
</p>
69+
70+
<h1 class="font-mono text-2xl sm:text-3xl font-medium mb-4">
71+
{{ statusMessage }}
72+
</h1>
73+
74+
<p
75+
v-if="error.message && error.message !== statusMessage"
76+
class="text-fg-muted text-base max-w-md mb-8"
77+
>
78+
{{ error.message }}
79+
</p>
80+
81+
<button
82+
type="button"
83+
class="font-mono text-sm px-6 py-3 bg-fg text-bg rounded-lg transition-all duration-200 hover:bg-fg/90 active:scale-95"
84+
@click="handleError"
85+
>
86+
go home
87+
</button>
88+
</main>
89+
90+
<footer class="border-t border-border mt-auto">
91+
<div class="container py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-fg-subtle text-sm">
92+
<p class="font-mono m-0">
93+
a better npm browser
94+
</p>
95+
<div class="flex items-center gap-6">
96+
<a
97+
href="https://github.com/danielroe/npmx.dev"
98+
rel="noopener noreferrer"
99+
class="link-subtle font-mono text-xs"
100+
>
101+
source
102+
</a>
103+
<span class="text-border">|</span>
104+
<a
105+
href="https://roe.dev"
106+
rel="noopener noreferrer"
107+
class="link-subtle font-mono text-xs"
108+
>
109+
@danielroe
110+
</a>
111+
</div>
112+
</div>
113+
</footer>
114+
</div>
115+
</template>

app/pages/index.vue

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
<script setup lang="ts">
22
const router = useRouter()
33
const searchQuery = ref('')
4-
const inputRef = ref<HTMLInputElement>()
54
const isSearchFocused = ref(false)
65
76
function handleSearch() {
8-
if (searchQuery.value.trim()) {
9-
router.push({ path: '/search', query: { q: searchQuery.value.trim() } })
10-
}
7+
router.push({
8+
path: '/search',
9+
query: searchQuery.value.trim() ? { q: searchQuery.value.trim() } : undefined,
10+
})
1111
}
1212
1313
useSeoMeta({
@@ -22,7 +22,7 @@ useSeoMeta({
2222
<header class="min-h-[calc(100vh-12rem)] flex flex-col items-center justify-center text-center py-20">
2323
<!-- Animated title -->
2424
<h1 class="font-mono text-5xl sm:text-7xl md:text-8xl font-medium tracking-tight mb-4 animate-fade-in animate-fill-both">
25-
<span class="text-fg-subtle">./</span>npmx
25+
<span class="text-fg-subtle"><span style="letter-spacing: -0.2em;">.</span>/</span>npmx
2626
</h1>
2727

2828
<p
@@ -57,40 +57,32 @@ useSeoMeta({
5757
class="absolute -inset-px rounded-lg bg-gradient-to-r from-fg/0 via-fg/5 to-fg/0 opacity-0 transition-opacity duration-500 blur-sm group-[.is-focused]:opacity-100"
5858
/>
5959

60-
<div class="relative flex items-center">
61-
<span class="absolute left-4 text-fg-subtle font-mono text-sm pointer-events-none transition-colors duration-200 group-focus-within:text-fg-muted">
62-
$
60+
<div class="search-box relative flex items-center">
61+
<span class="absolute left-4 text-fg-subtle font-mono text-sm pointer-events-none transition-colors duration-200 group-focus-within:text-fg-muted z-1">
62+
/
6363
</span>
6464

6565
<input
6666
id="home-search"
67-
ref="inputRef"
6867
v-model="searchQuery"
6968
type="search"
7069
name="q"
7170
placeholder="search packages..."
7271
autocomplete="off"
73-
autofocus
74-
class="w-full bg-bg-subtle border border-border rounded-lg pl-9 pr-24 py-4 font-mono text-base text-fg placeholder:text-fg-subtle transition-all duration-300 focus:(border-border-hover outline-none)"
72+
class="w-full bg-bg-subtle border border-border rounded-lg pl-8 pr-24 py-4 font-mono text-base text-fg placeholder:text-fg-subtle transition-all duration-300 focus:(border-border-hover outline-none)"
73+
@input="handleSearch"
7574
@focus="isSearchFocused = true"
7675
@blur="isSearchFocused = false"
7776
>
7877

7978
<button
8079
type="submit"
81-
class="absolute right-2 px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:(bg-fg/90) active:scale-95 disabled:(opacity-50 cursor-not-allowed)"
82-
:disabled="!searchQuery.trim()"
80+
class="absolute right-2 px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 active:scale-95"
8381
>
8482
search
8583
</button>
8684
</div>
8785
</div>
88-
89-
<!-- Keyboard hint -->
90-
<p class="mt-3 text-fg-subtle text-xs font-mono">
91-
<kbd class="px-1.5 py-0.5 bg-bg-muted border border-border rounded text-[10px]">Enter</kbd>
92-
to search
93-
</p>
9486
</form>
9587
</search>
9688
</header>

0 commit comments

Comments
 (0)