Skip to content

Commit e2fc3f1

Browse files
committed
feat: add npm package search to dashboard with dedicated search input and API endpoint
1 parent 21a1ad7 commit e2fc3f1

File tree

2 files changed

+130
-3
lines changed

2 files changed

+130
-3
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { json } from '@sveltejs/kit';
2+
import type { RequestHandler } from './$types';
3+
4+
interface NpmPackage {
5+
name: string;
6+
description: string;
7+
version: string;
8+
}
9+
10+
interface NpmSearchResult {
11+
name: string;
12+
desc: string;
13+
type: 'npm';
14+
}
15+
16+
interface NpmRegistryResponse {
17+
objects: Array<{
18+
package: {
19+
name: string;
20+
description: string;
21+
version: string;
22+
};
23+
}>;
24+
}
25+
26+
export const GET: RequestHandler = async ({ url }) => {
27+
const query = url.searchParams.get('q')?.toLowerCase().trim();
28+
29+
if (!query || query.length < 2) {
30+
return json({ results: [], error: 'Query must be at least 2 characters' });
31+
}
32+
33+
try {
34+
const response = await fetch(
35+
`https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=30`,
36+
{ cf: { cacheTtl: 300, cacheEverything: true } } as RequestInit
37+
);
38+
39+
if (!response.ok) {
40+
throw new Error('Failed to search npm registry');
41+
}
42+
43+
const data = (await response.json()) as NpmRegistryResponse;
44+
45+
const results: NpmSearchResult[] = data.objects.map((obj) => ({
46+
name: obj.package.name,
47+
desc: obj.package.description || '',
48+
type: 'npm' as const
49+
}));
50+
51+
return json({ results });
52+
} catch (error) {
53+
console.error('npm search error:', error);
54+
return json({ results: [], error: 'Failed to search npm packages' }, { status: 500 });
55+
}
56+
};

src/routes/dashboard/+page.svelte

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@
5454
let searchLoading = $state(false);
5555
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
5656
57+
let npmSearch = $state('');
58+
let npmSearchResults = $state<SearchResult[]>([]);
59+
let npmSearchLoading = $state(false);
60+
let npmSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
61+
5762
let showImportModal = $state(false);
5863
let brewfileContent = $state('');
5964
let importLoading = $state(false);
@@ -111,6 +116,39 @@
111116
}, 300);
112117
}
113118
119+
async function searchNpm(query: string) {
120+
if (query.length < 2) {
121+
npmSearchResults = [];
122+
return;
123+
}
124+
125+
npmSearchLoading = true;
126+
try {
127+
const response = await fetch(`/api/npm/search?q=${encodeURIComponent(query)}`);
128+
const data = await response.json();
129+
npmSearchResults = data.results || [];
130+
} catch (e) {
131+
console.error('npm search failed:', e);
132+
npmSearchResults = [];
133+
} finally {
134+
npmSearchLoading = false;
135+
}
136+
}
137+
138+
function handleNpmSearchInput(value: string) {
139+
npmSearch = value;
140+
if (npmSearchDebounceTimer) {
141+
clearTimeout(npmSearchDebounceTimer);
142+
}
143+
if (value.length < 2) {
144+
npmSearchResults = [];
145+
return;
146+
}
147+
npmSearchDebounceTimer = setTimeout(() => {
148+
searchNpm(value);
149+
}, 300);
150+
}
151+
114152
function getExtraPackages(): string[] {
115153
const presetPkgs = new Set(getPresetPackages(formData.base_preset));
116154
return Array.from(selectedPackages.keys()).filter((pkg) => !presetPkgs.has(pkg));
@@ -601,20 +639,53 @@
601639
<span class="group-empty">No npm packages</span>
602640
{/if}
603641
</div>
642+
<div class="packages-search">
643+
<input
644+
type="text"
645+
class="search-input"
646+
value={npmSearch}
647+
oninput={(e) => handleNpmSearchInput(e.currentTarget.value)}
648+
placeholder="Search npm packages (e.g. typescript, eslint)"
649+
/>
650+
</div>
651+
{#if npmSearchLoading}
652+
<div class="search-status">Searching npm...</div>
653+
{:else if npmSearch.length >= 2 && npmSearchResults.length === 0}
654+
<div class="search-status">No npm packages found for "{npmSearch}"</div>
655+
{:else if npmSearch.length >= 2}
656+
<div class="packages-grid">
657+
{#each npmSearchResults as result}
658+
<button type="button" class="package-item" class:selected={selectedPackages.has(result.name)} onclick={() => togglePackage(result.name, 'npm')}>
659+
<span class="check-indicator">{selectedPackages.has(result.name) ? '' : ''}</span>
660+
<div class="package-content">
661+
<div class="package-info">
662+
<span class="package-name">{result.name}</span>
663+
<span class="package-type">npm</span>
664+
</div>
665+
{#if result.desc}
666+
<span class="package-desc">{result.desc.slice(0, 60)}{result.desc.length > 60 ? '...' : ''}</span>
667+
{/if}
668+
</div>
669+
</button>
670+
{/each}
671+
</div>
672+
{:else}
673+
<div class="search-hint">Type at least 2 characters to search npm packages</div>
674+
{/if}
604675
</div>
605676
<div class="packages-search">
606677
<input
607678
type="text"
608679
class="search-input"
609680
value={packageSearch}
610681
oninput={(e) => handleSearchInput(e.currentTarget.value)}
611-
placeholder="Search packages or enter tap (e.g. steipete/tap/codexbar)"
682+
placeholder="Search Homebrew packages or enter tap (e.g. steipete/tap/codexbar)"
612683
/>
613684
</div>
614685
{#if searchLoading}
615-
<div class="search-status">Searching...</div>
686+
<div class="search-status">Searching Homebrew...</div>
616687
{:else if packageSearch.length >= 2 && searchResults.length === 0}
617-
<div class="search-status">No packages found for "{packageSearch}"</div>
688+
<div class="search-status">No Homebrew packages found for "{packageSearch}"</div>
618689
{:else if packageSearch.length >= 2}
619690
<div class="packages-grid">
620691
{#each searchResults as result}

0 commit comments

Comments
 (0)