645 lines
19 KiB
JavaScript
645 lines
19 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';
|
|
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<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;
|
|
}
|
|
|
|
// 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 = `
|
|
<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">×</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>';
|
|
};
|
|
}
|
|
}
|