@jlcpcb/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Browser app for EasyEDA Component Browser
3
+ * Handles search, rendering, and user interactions
4
+ *
5
+ * Uses KiCad S-expression renderer for symbol/footprint previews
6
+ */
7
+
8
+ import { renderSymbolSvg, renderFootprintSvg } from './kicad-renderer.js'
9
+
10
+ // Types
11
+ interface SearchResult {
12
+ uuid: string
13
+ title: string
14
+ thumb: string
15
+ description: string
16
+ tags: string[]
17
+ package: string
18
+ packageUuid?: string
19
+ manufacturer?: string
20
+ owner: {
21
+ uuid: string
22
+ username: string
23
+ nickname: string
24
+ avatar?: string
25
+ }
26
+ docType: number
27
+ }
28
+
29
+ interface Pagination {
30
+ page: number
31
+ limit: number
32
+ total: number
33
+ totalPages: number
34
+ hasNext: boolean
35
+ hasPrev: boolean
36
+ }
37
+
38
+ interface SearchResponse {
39
+ results: SearchResult[]
40
+ pagination: Pagination
41
+ }
42
+
43
+ /**
44
+ * Component data returned by the API
45
+ * Contains KiCad S-expression strings for rendering
46
+ */
47
+ interface ComponentData {
48
+ uuid: string
49
+ title: string
50
+ description: string
51
+ symbolSexpr: string // KiCad symbol S-expression
52
+ footprintSexpr: string // KiCad footprint S-expression
53
+ model3d?: {
54
+ name: string
55
+ uuid: string
56
+ }
57
+ }
58
+
59
+ // State
60
+ let currentPage = 1
61
+ let currentQuery = ''
62
+ let currentSource = 'user'
63
+ let isLoading = false
64
+ let debounceTimer: number | null = null
65
+
66
+ // DOM elements
67
+ const searchInput = document.getElementById('search-input') as HTMLInputElement
68
+ const sourceSelect = document.getElementById('source-select') as HTMLSelectElement
69
+ const searchBtn = document.getElementById('search-btn') as HTMLButtonElement
70
+ const resultsGrid = document.getElementById('results-grid') as HTMLDivElement
71
+ const paginationDiv = document.getElementById('pagination') as HTMLDivElement
72
+ const loadingDiv = document.getElementById('loading') as HTMLDivElement
73
+ const modal = document.getElementById('preview-modal') as HTMLDivElement
74
+ const modalContent = document.getElementById('modal-content') as HTMLDivElement
75
+ const modalClose = document.getElementById('modal-close') as HTMLButtonElement
76
+
77
+ // Initialize
78
+ function init() {
79
+ // Get query from URL
80
+ const params = new URLSearchParams(window.location.search)
81
+ const q = params.get('q')
82
+ if (q) {
83
+ searchInput.value = q
84
+ currentQuery = q
85
+ performSearch()
86
+ }
87
+
88
+ // Event listeners
89
+ searchInput.addEventListener('input', handleSearchInput)
90
+ searchInput.addEventListener('keydown', (e) => {
91
+ if (e.key === 'Enter') {
92
+ e.preventDefault()
93
+ performSearch()
94
+ }
95
+ })
96
+ searchBtn.addEventListener('click', performSearch)
97
+ sourceSelect.addEventListener('change', () => {
98
+ currentSource = sourceSelect.value
99
+ if (currentQuery) performSearch()
100
+ })
101
+ modalClose.addEventListener('click', closeModal)
102
+ modal.addEventListener('click', (e) => {
103
+ if (e.target === modal) closeModal()
104
+ })
105
+ document.addEventListener('keydown', (e) => {
106
+ if (e.key === 'Escape') closeModal()
107
+ })
108
+ }
109
+
110
+ // Debounced search input
111
+ function handleSearchInput() {
112
+ if (debounceTimer) clearTimeout(debounceTimer)
113
+ debounceTimer = window.setTimeout(() => {
114
+ const query = searchInput.value.trim()
115
+ if (query && query !== currentQuery) {
116
+ currentQuery = query
117
+ currentPage = 1
118
+ performSearch()
119
+ }
120
+ }, 300)
121
+ }
122
+
123
+ // Perform search
124
+ async function performSearch() {
125
+ const query = searchInput.value.trim()
126
+ if (!query || isLoading) return
127
+
128
+ currentQuery = query
129
+ isLoading = true
130
+ showLoading(true)
131
+
132
+ try {
133
+ const params = new URLSearchParams({
134
+ q: query,
135
+ source: currentSource,
136
+ page: String(currentPage),
137
+ limit: '20',
138
+ })
139
+
140
+ const response = await fetch(`/api/search?${params}`)
141
+ if (!response.ok) throw new Error('Search failed')
142
+
143
+ const data: SearchResponse = await response.json()
144
+ renderResults(data.results)
145
+ renderPagination(data.pagination)
146
+ } catch (error) {
147
+ console.error('Search error:', error)
148
+ resultsGrid.innerHTML = `<div class="error">Search failed. Please try again.</div>`
149
+ } finally {
150
+ isLoading = false
151
+ showLoading(false)
152
+ }
153
+ }
154
+
155
+ // Render search results
156
+ function renderResults(results: SearchResult[]) {
157
+ if (results.length === 0) {
158
+ resultsGrid.innerHTML = `<div class="no-results">No components found. Try a different search term.</div>`
159
+ return
160
+ }
161
+
162
+ resultsGrid.innerHTML = results.map(result => `
163
+ <div class="card" data-uuid="${result.uuid}">
164
+ <div class="card-images">
165
+ <div class="image-container symbol-container" data-uuid="${result.uuid}">
166
+ <div class="symbol-placeholder">Loading...</div>
167
+ <div class="image-label">Symbol</div>
168
+ </div>
169
+ <div class="image-container footprint-container" data-uuid="${result.uuid}">
170
+ <div class="footprint-placeholder">Loading...</div>
171
+ <div class="image-label">Footprint</div>
172
+ </div>
173
+ </div>
174
+ <div class="card-info">
175
+ <div class="card-title" title="${escapeHtml(result.title)}">${escapeHtml(result.title)}</div>
176
+ <div class="card-package">Package: ${escapeHtml(result.package || 'Unknown')}</div>
177
+ <div class="card-owner">By: ${escapeHtml(result.owner.nickname || result.owner.username)}</div>
178
+ </div>
179
+ <div class="card-uuid">
180
+ <input type="text" value="${result.uuid}" readonly />
181
+ <button class="copy-btn" data-uuid="${result.uuid}" title="Copy UUID">
182
+ <svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
183
+ </button>
184
+ </div>
185
+ </div>
186
+ `).join('')
187
+
188
+ // Add copy button handlers
189
+ document.querySelectorAll('.copy-btn').forEach(btn => {
190
+ btn.addEventListener('click', (e) => {
191
+ e.stopPropagation()
192
+ const uuid = (btn as HTMLElement).dataset.uuid
193
+ if (uuid) copyToClipboard(uuid, btn as HTMLElement)
194
+ })
195
+ })
196
+
197
+ // Add hover handlers for enlarging
198
+ document.querySelectorAll('.image-container').forEach(container => {
199
+ container.addEventListener('click', async () => {
200
+ const uuid = (container as HTMLElement).dataset.uuid
201
+ if (uuid) showPreviewModal(uuid)
202
+ })
203
+ })
204
+
205
+ // Load symbol and footprint previews for each card
206
+ results.forEach(result => loadComponentPreviews(result.uuid))
207
+ }
208
+
209
+ // Load and render both symbol and footprint previews for a card
210
+ async function loadComponentPreviews(uuid: string) {
211
+ const symbolContainer = document.querySelector(`.symbol-container[data-uuid="${uuid}"]`)
212
+ const footprintContainer = document.querySelector(`.footprint-container[data-uuid="${uuid}"]`)
213
+ if (!symbolContainer && !footprintContainer) return
214
+
215
+ try {
216
+ const response = await fetch(`/api/component/${uuid}`)
217
+ if (!response.ok) {
218
+ if (symbolContainer) {
219
+ symbolContainer.innerHTML = '<div class="no-preview">Error</div><div class="image-label">Symbol</div>'
220
+ }
221
+ if (footprintContainer) {
222
+ footprintContainer.innerHTML = '<div class="no-preview">Error</div><div class="image-label">Footprint</div>'
223
+ }
224
+ return
225
+ }
226
+
227
+ const data: ComponentData = await response.json()
228
+
229
+ // Render symbol from KiCad S-expression
230
+ if (symbolContainer) {
231
+ if (data.symbolSexpr) {
232
+ const svg = renderSymbolSvg(data.symbolSexpr)
233
+ symbolContainer.innerHTML = svg + '<div class="image-label">Symbol</div>'
234
+ } else {
235
+ symbolContainer.innerHTML = '<div class="no-preview">No preview</div><div class="image-label">Symbol</div>'
236
+ }
237
+ }
238
+
239
+ // Render footprint from KiCad S-expression
240
+ if (footprintContainer) {
241
+ if (data.footprintSexpr) {
242
+ const svg = renderFootprintSvg(data.footprintSexpr)
243
+ footprintContainer.innerHTML = svg + '<div class="image-label">Footprint</div>'
244
+ } else {
245
+ footprintContainer.innerHTML = '<div class="no-preview">No preview</div><div class="image-label">Footprint</div>'
246
+ }
247
+ }
248
+ } catch {
249
+ if (symbolContainer) {
250
+ symbolContainer.innerHTML = '<div class="no-preview">Error</div><div class="image-label">Symbol</div>'
251
+ }
252
+ if (footprintContainer) {
253
+ footprintContainer.innerHTML = '<div class="no-preview">Error</div><div class="image-label">Footprint</div>'
254
+ }
255
+ }
256
+ }
257
+
258
+ // Show preview modal
259
+ async function showPreviewModal(uuid: string) {
260
+ modal.classList.remove('hidden')
261
+
262
+ modalContent.innerHTML = '<div class="modal-loading">Loading...</div>'
263
+
264
+ try {
265
+ const response = await fetch(`/api/component/${uuid}`)
266
+ if (!response.ok) throw new Error('Failed to fetch component')
267
+
268
+ const data: ComponentData = await response.json()
269
+
270
+ // Generate SVG previews from KiCad S-expressions
271
+ const symbolSvg = data.symbolSexpr ? renderSymbolSvg(data.symbolSexpr) : ''
272
+ const footprintSvg = data.footprintSexpr ? renderFootprintSvg(data.footprintSexpr) : ''
273
+
274
+ modalContent.innerHTML = `
275
+ <div class="modal-header">
276
+ <h2>${escapeHtml(data.title)}</h2>
277
+ <div class="modal-uuid">
278
+ <code>${data.uuid}</code>
279
+ <button class="copy-btn modal-copy" data-uuid="${data.uuid}">Copy UUID</button>
280
+ </div>
281
+ </div>
282
+ <div class="modal-previews">
283
+ <div class="modal-preview">
284
+ <h3>Symbol (KiCad)</h3>
285
+ <div class="preview-svg">${symbolSvg || '<div class="no-preview">No symbol preview</div>'}</div>
286
+ </div>
287
+ <div class="modal-preview">
288
+ <h3>Footprint (KiCad)</h3>
289
+ <div class="preview-svg">${footprintSvg || '<div class="no-preview">No footprint preview</div>'}</div>
290
+ </div>
291
+ </div>
292
+ <div class="modal-info">
293
+ <p><strong>Description:</strong> ${escapeHtml(data.description || 'N/A')}</p>
294
+ ${data.model3d ? '<p><strong>3D Model:</strong> Available</p>' : ''}
295
+ </div>
296
+ `
297
+
298
+ // Add copy handler for modal
299
+ const modalCopyBtn = modalContent.querySelector('.modal-copy')
300
+ if (modalCopyBtn) {
301
+ modalCopyBtn.addEventListener('click', () => {
302
+ copyToClipboard(data.uuid, modalCopyBtn as HTMLElement)
303
+ })
304
+ }
305
+ } catch (error) {
306
+ console.error('Modal error:', error)
307
+ modalContent.innerHTML = '<div class="modal-error">Failed to load component details</div>'
308
+ }
309
+ }
310
+
311
+ // Close modal
312
+ function closeModal() {
313
+ modal.classList.add('hidden')
314
+ }
315
+
316
+ // Render pagination
317
+ function renderPagination(pagination: Pagination) {
318
+ if (pagination.totalPages <= 1) {
319
+ paginationDiv.innerHTML = ''
320
+ return
321
+ }
322
+
323
+ paginationDiv.innerHTML = `
324
+ <button class="page-btn" ${pagination.hasPrev ? '' : 'disabled'} data-page="${pagination.page - 1}">
325
+ ← Prev
326
+ </button>
327
+ <span class="page-info">Page ${pagination.page} of ${pagination.totalPages}</span>
328
+ <button class="page-btn" ${pagination.hasNext ? '' : 'disabled'} data-page="${pagination.page + 1}">
329
+ Next →
330
+ </button>
331
+ `
332
+
333
+ paginationDiv.querySelectorAll('.page-btn').forEach(btn => {
334
+ btn.addEventListener('click', () => {
335
+ const page = parseInt((btn as HTMLElement).dataset.page || '1', 10)
336
+ if (page > 0) {
337
+ currentPage = page
338
+ performSearch()
339
+ window.scrollTo(0, 0)
340
+ }
341
+ })
342
+ })
343
+ }
344
+
345
+ // Copy to clipboard
346
+ async function copyToClipboard(text: string, btn: HTMLElement) {
347
+ try {
348
+ await navigator.clipboard.writeText(text)
349
+ btn.classList.add('copied')
350
+ setTimeout(() => btn.classList.remove('copied'), 1500)
351
+ } catch {
352
+ // Fallback for older browsers
353
+ const input = document.createElement('input')
354
+ input.value = text
355
+ document.body.appendChild(input)
356
+ input.select()
357
+ document.execCommand('copy')
358
+ document.body.removeChild(input)
359
+ btn.classList.add('copied')
360
+ setTimeout(() => btn.classList.remove('copied'), 1500)
361
+ }
362
+ }
363
+
364
+ // Show/hide loading
365
+ function showLoading(show: boolean) {
366
+ loadingDiv.classList.toggle('hidden', !show)
367
+ }
368
+
369
+ // Escape HTML
370
+ function escapeHtml(str: string): string {
371
+ const div = document.createElement('div')
372
+ div.textContent = str
373
+ return div.innerHTML
374
+ }
375
+
376
+ // Initialize on DOM ready
377
+ if (document.readyState === 'loading') {
378
+ document.addEventListener('DOMContentLoaded', init)
379
+ } else {
380
+ init()
381
+ }