/** * Browser app for EasyEDA Component Browser * Handles search, rendering, and user interactions * * Uses KiCad S-expression renderer for symbol/footprint previews */ import { renderSymbolSvg, renderFootprintSvg } from './kicad-renderer.js' // Types interface SearchResult { uuid: string title: string thumb: string description: string tags: string[] package: string packageUuid?: string manufacturer?: string owner: { uuid: string username: string nickname: string avatar?: string } docType: number } interface Pagination { page: number limit: number total: number totalPages: number hasNext: boolean hasPrev: boolean } interface SearchResponse { results: SearchResult[] pagination: Pagination } /** * Component data returned by the API * Contains KiCad S-expression strings for rendering */ interface ComponentData { uuid: string title: string description: string symbolSexpr: string // KiCad symbol S-expression footprintSexpr: string // KiCad footprint S-expression model3d?: { name: string uuid: string } } // State let currentPage = 1 let currentQuery = '' let currentSource = 'user' let isLoading = false let debounceTimer: number | null = null // DOM elements const searchInput = document.getElementById('search-input') as HTMLInputElement const sourceSelect = document.getElementById('source-select') as HTMLSelectElement const searchBtn = document.getElementById('search-btn') as HTMLButtonElement const resultsGrid = document.getElementById('results-grid') as HTMLDivElement const paginationDiv = document.getElementById('pagination') as HTMLDivElement const loadingDiv = document.getElementById('loading') as HTMLDivElement const modal = document.getElementById('preview-modal') as HTMLDivElement const modalContent = document.getElementById('modal-content') as HTMLDivElement const modalClose = document.getElementById('modal-close') as HTMLButtonElement // Initialize function init() { // Check for server-injected query first const initialQuery = (window as unknown as { __INITIAL_QUERY__?: string }).__INITIAL_QUERY__ if (initialQuery) { searchInput.value = initialQuery currentQuery = initialQuery performSearch() } else { // Fallback to URL param const params = new URLSearchParams(window.location.search) const q = params.get('q') if (q) { searchInput.value = q currentQuery = q performSearch() } } // Event listeners searchInput.addEventListener('input', handleSearchInput) searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault() performSearch() } }) searchBtn.addEventListener('click', performSearch) sourceSelect.addEventListener('change', () => { currentSource = sourceSelect.value if (currentQuery) performSearch() }) modalClose.addEventListener('click', closeModal) modal.addEventListener('click', (e) => { if (e.target === modal) closeModal() }) document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal() }) } // Debounced search input function handleSearchInput() { if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = window.setTimeout(() => { const query = searchInput.value.trim() if (query && query !== currentQuery) { currentQuery = query currentPage = 1 performSearch() } }, 300) } // Perform search async function performSearch() { const query = searchInput.value.trim() if (!query || isLoading) return currentQuery = query isLoading = true showLoading(true) try { const params = new URLSearchParams({ q: query, source: currentSource, page: String(currentPage), limit: '20', }) const response = await fetch(`/api/search?${params}`) if (!response.ok) throw new Error('Search failed') const data: SearchResponse = await response.json() renderResults(data.results) renderPagination(data.pagination) } catch (error) { console.error('Search error:', error) resultsGrid.innerHTML = `
Search failed. Please try again.
` } finally { isLoading = false showLoading(false) } } // Render search results function renderResults(results: SearchResult[]) { if (results.length === 0) { resultsGrid.innerHTML = `
No components found. Try a different search term.
` return } resultsGrid.innerHTML = results.map(result => `
Loading...
Symbol
Loading...
Footprint
${escapeHtml(result.title)}
Package: ${escapeHtml(result.package || 'Unknown')}
By: ${escapeHtml(result.owner.nickname || result.owner.username)}
`).join('') // Add copy button handlers document.querySelectorAll('.copy-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation() const uuid = (btn as HTMLElement).dataset.uuid if (uuid) copyToClipboard(uuid, btn as HTMLElement) }) }) // Add hover handlers for enlarging document.querySelectorAll('.image-container').forEach(container => { container.addEventListener('click', async () => { const uuid = (container as HTMLElement).dataset.uuid if (uuid) showPreviewModal(uuid) }) }) // Load symbol and footprint previews for each card results.forEach(result => loadComponentPreviews(result.uuid)) } // Load and render both symbol and footprint previews for a card async function loadComponentPreviews(uuid: string) { const symbolContainer = document.querySelector(`.symbol-container[data-uuid="${uuid}"]`) const footprintContainer = document.querySelector(`.footprint-container[data-uuid="${uuid}"]`) if (!symbolContainer && !footprintContainer) return try { const response = await fetch(`/api/component/${uuid}`) if (!response.ok) { if (symbolContainer) { symbolContainer.innerHTML = '
Error
Symbol
' } if (footprintContainer) { footprintContainer.innerHTML = '
Error
Footprint
' } return } const data: ComponentData = await response.json() // Render symbol from KiCad S-expression if (symbolContainer) { if (data.symbolSexpr) { const svg = renderSymbolSvg(data.symbolSexpr) symbolContainer.innerHTML = svg + '
Symbol
' } else { symbolContainer.innerHTML = '
No preview
Symbol
' } } // Render footprint from KiCad S-expression if (footprintContainer) { if (data.footprintSexpr) { const svg = renderFootprintSvg(data.footprintSexpr) footprintContainer.innerHTML = svg + '
Footprint
' } else { footprintContainer.innerHTML = '
No preview
Footprint
' } } } catch { if (symbolContainer) { symbolContainer.innerHTML = '
Error
Symbol
' } if (footprintContainer) { footprintContainer.innerHTML = '
Error
Footprint
' } } } // Show preview modal async function showPreviewModal(uuid: string) { modal.classList.remove('hidden') modalContent.innerHTML = '' try { const response = await fetch(`/api/component/${uuid}`) if (!response.ok) throw new Error('Failed to fetch component') const data: ComponentData = await response.json() // Generate SVG previews from KiCad S-expressions const symbolSvg = data.symbolSexpr ? renderSymbolSvg(data.symbolSexpr) : '' const footprintSvg = data.footprintSexpr ? renderFootprintSvg(data.footprintSexpr) : '' modalContent.innerHTML = ` ` // Add copy handler for modal const modalCopyBtn = modalContent.querySelector('.modal-copy') if (modalCopyBtn) { modalCopyBtn.addEventListener('click', () => { copyToClipboard(data.uuid, modalCopyBtn as HTMLElement) }) } } catch (error) { console.error('Modal error:', error) modalContent.innerHTML = '' } } // Close modal function closeModal() { modal.classList.add('hidden') } // Render pagination function renderPagination(pagination: Pagination) { if (pagination.totalPages <= 1) { paginationDiv.innerHTML = '' return } paginationDiv.innerHTML = ` Page ${pagination.page} of ${pagination.totalPages} ` paginationDiv.querySelectorAll('.page-btn').forEach(btn => { btn.addEventListener('click', () => { const page = parseInt((btn as HTMLElement).dataset.page || '1', 10) if (page > 0) { currentPage = page performSearch() window.scrollTo(0, 0) } }) }) } // Copy to clipboard async function copyToClipboard(text: string, btn: HTMLElement) { try { await navigator.clipboard.writeText(text) btn.classList.add('copied') setTimeout(() => btn.classList.remove('copied'), 1500) } catch { // Fallback for older browsers const input = document.createElement('input') input.value = text document.body.appendChild(input) input.select() document.execCommand('copy') document.body.removeChild(input) btn.classList.add('copied') setTimeout(() => btn.classList.remove('copied'), 1500) } } // Show/hide loading function showLoading(show: boolean) { loadingDiv.classList.toggle('hidden', !show) } // Escape HTML function escapeHtml(str: string): string { const div = document.createElement('div') div.textContent = str return div.innerHTML } // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init) } else { init() }