/**
* 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 => `
${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 = 'Loading...
'
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 = `
Symbol (KiCad)
${symbolSvg || '
No symbol preview
'}
Footprint (KiCad)
${footprintSvg || '
No footprint preview
'}
Description: ${escapeHtml(data.description || 'N/A')}
${data.model3d ? '
3D Model: Available
' : ''}
`
// 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 = 'Failed to load component details
'
}
}
// 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()
}