freealberta/map/app/public/js/search-manager.js

629 lines
18 KiB
JavaScript

/**
* 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';
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<boolean>} 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;
}
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;
}
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 = `
<a href="${result.url || '#'}" class="search-result-link" target="_blank" rel="noopener">
<div class="result-title">${result.title || 'Untitled'}</div>
<div class="result-excerpt">${result.snippet || result.excerpt || ''}</div>
<div class="result-path">${result.location || result.path || ''}</div>
</a>
<button class="btn btn-sm btn-secondary make-qr-btn" data-url="${result.url || '#'}" title="Generate QR Code">
<span class="btn-icon">📱</span>
<span class="btn-text">QR</span>
</button>
`;
// 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 = `
<div class="search-loading">
Searching...
</div>
`;
this.showResults();
}
/**
* Show no results message
*/
showNoResults() {
if (!this.resultsList) return;
this.resultsList.innerHTML = `
<div class="search-no-results">
No results found for "${this.searchInput.value.trim()}"
</div>
`;
this.updateResultsCount(0);
this.showResults();
}
/**
* Show error message
* @param {string} message - Error message
*/
showError(message) {
if (!this.resultsList) return;
this.resultsList.innerHTML = `
<div class="search-no-results">
Error: ${message}
</div>
`;
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, '<mark>$1</mark>');
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 = `
<div class="modal-content qr-modal-content">
<div class="modal-header">
<h2>QR Code</h2>
<button class="modal-close" id="close-qr-modal">&times;</button>
</div>
<div class="modal-body qr-modal-body">
<div class="qr-loading">
<div class="spinner"></div>
<p>Generating QR code...</p>
</div>
<img class="qr-code-image" alt="QR Code" style="display: none;">
<div class="qr-code-info">
<p>Scan this QR code to open:</p>
<a class="qr-code-url" href="${url}" target="_blank" rel="noopener">${url}</a>
</div>
</div>
</div>
`;
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 = '<div class="qr-error">Failed to generate QR code</div>';
};
}
}