/** * Unified Search Manager * Manages the combined documentation and map search functionality */ import { MkDocsSearch } from './mkdocs-search.js'; import mapSearch from './map-search.js'; import databaseSearch from './database-search.js'; import { currentUser } from './auth.js'; export class UnifiedSearchManager { constructor(config = {}) { this.mode = 'docs'; // 'docs', 'map', or 'database' this.mkdocsSearch = null; this.mapSearch = mapSearch; this.databaseSearch = databaseSearch; // Add this line this.debounceTimeout = null; this.config = config; // DOM elements this.container = null; this.searchInput = null; this.searchResults = null; this.modeButtons = null; this.resultsHeader = null; this.resultsList = null; this.closeButton = null; this.isInitialized = false; } /** * Initialize the unified search * @returns {Promise} Success status */ async initialize() { try { console.log('Initializing Unified Search Manager...'); // Initialize MkDocs search this.mkdocsSearch = new MkDocsSearch(this.config); const mkdocsInitialized = await this.mkdocsSearch.initialize(); if (!mkdocsInitialized) { console.warn('MkDocs search could not be initialized'); } this.isInitialized = true; console.log('Unified Search Manager initialized successfully'); return true; } catch (error) { console.error('Failed to initialize Unified Search Manager:', error); return false; } } /** * Bind the search to DOM elements * @param {HTMLElement} container - The search container element */ bindToElements(container) { this.container = container; this.searchInput = container.querySelector('.unified-search-input'); this.searchResults = container.querySelector('.unified-search-results'); this.modeButtons = container.querySelectorAll('.search-mode-btn'); this.resultsHeader = container.querySelector('.unified-search-results-header'); this.resultsList = container.querySelector('.unified-search-results-list'); this.closeButton = container.querySelector('.close-results'); if (!this.searchInput || !this.searchResults) { console.error('Required search elements not found'); return false; } // Hide database search option for temp users if (currentUser?.userType === 'temp') { const databaseModeBtn = container.querySelector('[data-mode="database"]'); if (databaseModeBtn) { databaseModeBtn.style.display = 'none'; } } this.setupEventListeners(); this.updatePlaceholder(); return true; } /** * Set up event listeners */ setupEventListeners() { // Search input events this.searchInput.addEventListener('input', (e) => { this.handleSearchInput(e.target.value); }); this.searchInput.addEventListener('keydown', (e) => { this.handleKeyDown(e); }); this.searchInput.addEventListener('focus', () => { if (this.searchInput.value.trim()) { this.showResults(); } // Prevent zoom on mobile iOS if (/iPhone|iPad|iPod/.test(navigator.userAgent)) { this.searchInput.style.fontSize = '16px'; } }); this.searchInput.addEventListener('blur', () => { // Small delay to allow clicking on results setTimeout(() => { // Only hide if not clicking within search container if (!this.container.matches(':hover')) { // this.hideResults(); } }, 150); }); // Mode toggle buttons this.modeButtons.forEach(btn => { btn.addEventListener('click', () => { const mode = btn.dataset.mode; if (mode && mode !== this.mode) { this.setMode(mode); } }); }); // Close button if (this.closeButton) { this.closeButton.addEventListener('click', () => { this.hideResults(); }); } // Global keyboard shortcuts document.addEventListener('keydown', (e) => { // Ctrl+K to focus search if (e.ctrlKey && e.key === 'k') { e.preventDefault(); this.focusSearch(); } // Ctrl+Shift+D for docs mode if (e.ctrlKey && e.shiftKey && e.key === 'D') { e.preventDefault(); this.setMode('docs'); this.focusSearch(); } // Ctrl+Shift+M for map mode if (e.ctrlKey && e.shiftKey && e.key === 'M') { e.preventDefault(); this.setMode('map'); this.focusSearch(); } // Ctrl+Shift+B for database mode if (e.ctrlKey && e.shiftKey && e.key === 'B') { e.preventDefault(); this.setMode('database'); this.focusSearch(); } // Escape to close results if (e.key === 'Escape') { this.hideResults(); } }); // Click outside to close results document.addEventListener('click', (e) => { if (!this.container.contains(e.target)) { this.hideResults(); } }); // Touch events for mobile document.addEventListener('touchstart', (e) => { if (!this.container.contains(e.target)) { this.hideResults(); } }); } /** * Set the search mode * @param {string} mode - 'docs' or 'map' */ setMode(mode) { if (mode !== 'docs' && mode !== 'map' && mode !== 'database') { console.error('Invalid search mode:', mode); return; } // Prevent database search for temp users if (currentUser?.userType === 'temp' && mode === 'database') { console.log('Database search not available for temporary users'); this.showError('Database search is not available for temporary users'); return; } this.mode = mode; // Update button states this.modeButtons.forEach(btn => { btn.classList.toggle('active', btn.dataset.mode === mode); }); this.updatePlaceholder(); this.clearResults(); // If there's a current search, re-run it in the new mode const currentQuery = this.searchInput.value.trim(); if (currentQuery) { this.handleSearchInput(currentQuery); } console.log('Search mode changed to:', mode); } /** * Update the search input placeholder */ updatePlaceholder() { if (!this.searchInput) return; const placeholders = { docs: 'Search documentation... (Ctrl+K)', map: 'Search addresses... (Ctrl+K)', database: 'Search locations... (Ctrl+K)' }; this.searchInput.placeholder = placeholders[this.mode] || 'Search...'; } /** * Handle search input * @param {string} query - Search query */ handleSearchInput(query) { // Clear previous debounce if (this.debounceTimeout) { clearTimeout(this.debounceTimeout); } // Debounce the search this.debounceTimeout = setTimeout(() => { this.performSearch(query); }, 300); } /** * Perform the actual search * @param {string} query - Search query */ async performSearch(query) { const trimmedQuery = query.trim(); if (!trimmedQuery) { this.clearResults(); return; } if (trimmedQuery.length < 2) { this.clearResults(); return; } try { this.showLoading(); let results = []; if (this.mode === 'docs' && this.mkdocsSearch) { results = await this.mkdocsSearch.search(trimmedQuery); } else if (this.mode === 'map') { results = await this.mapSearch.search(trimmedQuery); } else if (this.mode === 'database') { results = await this.databaseSearch.search(trimmedQuery); } this.displayResults(results, trimmedQuery); } catch (error) { console.error('Search error:', error); this.showError(error.message); } } /** * Display search results * @param {Array} results - Search results * @param {string} query - Original query */ displayResults(results, query) { if (!this.resultsList) return; this.resultsList.innerHTML = ''; if (results.length === 0) { this.showNoResults(); return; } // Update results count this.updateResultsCount(results.length, query); // Create result elements results.forEach(result => { let resultEl; if (this.mode === 'docs') { resultEl = this.createDocsResultElement(result); } else if (this.mode === 'map') { resultEl = this.mapSearch.createResultElement(result); } else if (this.mode === 'database') { resultEl = this.databaseSearch.createResultElement(result); } if (resultEl) { this.resultsList.appendChild(resultEl); } }); this.showResults(); } /** * Create a documentation search result element * @param {Object} result - Search result * @returns {HTMLElement} Result element */ createDocsResultElement(result) { const resultEl = document.createElement('div'); resultEl.className = 'search-result-item search-result-docs'; resultEl.innerHTML = `
${result.title || 'Untitled'}
${result.snippet || result.excerpt || ''}
${result.location || result.path || ''}
`; // Add QR button event listener const qrButton = resultEl.querySelector('.make-qr-btn'); qrButton.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const url = qrButton.getAttribute('data-url'); this.showQRCodeModal(url); }); // Add click handler to hide results when link is clicked const link = resultEl.querySelector('.search-result-link'); link.addEventListener('click', () => { this.hideResults(); }); return resultEl; } /** * Show loading state */ showLoading() { if (!this.resultsList) return; this.resultsList.innerHTML = `
Searching...
`; this.showResults(); } /** * Show no results message */ showNoResults() { if (!this.resultsList) return; this.resultsList.innerHTML = `
No results found for "${this.searchInput.value.trim()}"
`; this.updateResultsCount(0); this.showResults(); } /** * Show error message * @param {string} message - Error message */ showError(message) { if (!this.resultsList) return; this.resultsList.innerHTML = `
Error: ${message}
`; this.showResults(); } /** * Update results count display * @param {number} count - Number of results * @param {string} query - Search query */ updateResultsCount(count, query = '') { if (!this.resultsHeader) return; const countEl = this.resultsHeader.querySelector('.results-count'); if (countEl) { if (count === 0) { countEl.textContent = 'No results'; } else if (count === 1) { countEl.textContent = '1 result'; } else { countEl.textContent = `${count} results`; } } } /** * Show search results */ showResults() { if (this.searchResults) { this.searchResults.classList.remove('hidden'); } } /** * Hide search results */ hideResults() { if (this.searchResults) { this.searchResults.classList.add('hidden'); } } /** * Clear search results */ clearResults() { if (this.resultsList) { this.resultsList.innerHTML = ''; } this.hideResults(); } /** * Focus the search input */ focusSearch() { if (this.searchInput) { this.searchInput.focus(); this.searchInput.select(); } } /** * Handle keyboard navigation * @param {KeyboardEvent} e - Keyboard event */ handleKeyDown(e) { // Handle Enter key if (e.key === 'Enter') { const firstResult = this.resultsList?.querySelector('.search-result-item'); if (firstResult) { firstResult.click(); } } // Handle Escape key if (e.key === 'Escape') { this.hideResults(); this.searchInput.blur(); } } /** * Get current search mode * @returns {string} Current mode */ getMode() { return this.mode; } /** * Check if search is initialized * @returns {boolean} Initialization status */ isReady() { return this.isInitialized; } /** * Extract text snippet from document content with search term highlighted * @param {Object} doc - Document object * @param {string} searchTerm - Search term to highlight * @returns {string} HTML snippet with highlights */ extractSnippet(doc, searchTerm) { if (!doc.text) return ''; const text = doc.text; const lowerText = text.toLowerCase(); const lowerTerm = searchTerm.toLowerCase(); // Find the first occurrence of the search term let index = lowerText.indexOf(lowerTerm); if (index === -1) { // If exact term not found, try first word of search term const firstWord = lowerTerm.split(' ')[0]; index = lowerText.indexOf(firstWord); } if (index === -1) { // Return first 200 characters if no match found return text.substring(0, 200) + (text.length > 200 ? '...' : ''); } // Extract snippet around the match const snippetLength = 200; const start = Math.max(0, index - 50); const end = Math.min(text.length, start + snippetLength); let snippet = text.substring(start, end); // Add ellipsis if we're not at the beginning/end if (start > 0) snippet = '...' + snippet; if (end < text.length) snippet = snippet + '...'; // Highlight the search term (case-insensitive) const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); snippet = snippet.replace(regex, '$1'); return snippet; } /** * Show QR code modal * @param {string} url - URL to generate QR code for */ showQRCodeModal(url) { // Remove existing modal this.hideQRCodeModal(); // Create modal using the same structure as the original const modal = document.createElement('div'); modal.id = 'qr-code-modal'; modal.className = 'modal qr-modal'; modal.innerHTML = ` `; document.body.appendChild(modal); // Add event listeners const closeBtn = modal.querySelector('#close-qr-modal'); closeBtn.addEventListener('click', () => this.hideQRCodeModal()); modal.addEventListener('click', (e) => { if (e.target === modal) { this.hideQRCodeModal(); } }); // Close on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.style.display !== 'none') { this.hideQRCodeModal(); } }); // Generate QR code this.generateQRCode(url, modal.querySelector('.qr-loading')); } /** * Hide QR code modal */ hideQRCodeModal() { const existingModal = document.querySelector('#qr-code-modal'); if (existingModal) { existingModal.remove(); } } /** * Generate QR code for URL * @param {string} url - URL to encode * @param {HTMLElement} container - Container to place QR code */ generateQRCode(url, container) { // Use the API QR route as in the original implementation const qrUrl = `/api/qr?text=${encodeURIComponent(url)}&size=256`; const img = document.createElement('img'); img.src = qrUrl; img.alt = 'QR Code'; img.className = 'qr-code-image'; img.onload = () => { container.innerHTML = ''; container.appendChild(img); }; img.onerror = () => { container.innerHTML = '
Failed to generate QR code
'; }; } }