freealberta/map/app/public/js/mkdocs-search.js
2025-07-22 12:25:12 -06:00

351 lines
13 KiB
JavaScript

/**
* MkDocs Search Integration
* Integrates MkDocs Material's search functionality into the map application
*/
export class MkDocsSearch {
constructor(config = {}) {
// Determine if we're in production based on current URL
const isProduction = window.location.hostname !== 'localhost' && !window.location.hostname.includes('127.0.0.1');
// Use production URL if we're not on localhost
if (isProduction && config.mkdocsUrl && config.mkdocsUrl.includes('localhost')) {
// Extract the base domain from the current hostname
// If we're on map.cmlite.org, we want cmlite.org
const currentDomain = window.location.hostname.replace(/^map\./, '');
this.mkdocsUrl = `https://${currentDomain}`;
} else {
this.mkdocsUrl = config.mkdocsUrl || window.MKDOCS_URL || 'http://localhost:4002';
}
this.searchIndex = null;
this.searchDocs = null;
this.lunr = null;
this.debounceTimeout = null;
this.minSearchLength = config.minSearchLength || 2;
this.initialized = false;
console.log('MkDocs Search initialized with URL:', this.mkdocsUrl);
}
async initialize() {
try {
console.log('Initializing MkDocs search...');
// Load Lunr.js dynamically
await this.loadLunr();
// Try multiple approaches to get the search index
let searchData = null;
// First try the proxy endpoint
try {
console.log('Trying proxy endpoint: /api/docs-search');
const proxyResponse = await fetch('/api/docs-search');
if (proxyResponse.ok) {
searchData = await proxyResponse.json();
console.log('Successfully loaded search index via proxy');
}
} catch (proxyError) {
console.warn('Proxy endpoint failed:', proxyError);
}
// If proxy fails, try direct access
if (!searchData) {
console.log(`Trying direct access: ${this.mkdocsUrl}/search/search_index.json`);
const response = await fetch(`${this.mkdocsUrl}/search/search_index.json`);
if (!response.ok) {
throw new Error(`Failed to load search index: ${response.status}`);
}
searchData = await response.json();
console.log('Successfully loaded search index directly');
}
// Build the Lunr index
this.searchIndex = this.lunr(function() {
this.ref('location');
this.field('title', { boost: 10 });
this.field('text');
searchData.docs.forEach(doc => {
this.add(doc);
});
});
this.searchDocs = searchData.docs;
this.initialized = true;
console.log('MkDocs search initialized with', this.searchDocs.length, 'documents');
return true;
} catch (error) {
console.error('Failed to initialize MkDocs search:', error);
this.initialized = false;
return false;
}
}
async loadLunr() {
if (window.lunr) {
this.lunr = window.lunr;
return;
}
// Load Lunr.js from CDN
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://unpkg.com/lunr@2.3.9/lunr.min.js';
script.onload = () => {
this.lunr = window.lunr;
resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
}
search(query) {
if (!this.initialized) {
console.warn('Search not initialized');
return [];
}
if (!this.searchIndex || !query || query.length < this.minSearchLength) {
return [];
}
try {
// Perform the search
const results = this.searchIndex.search(query);
// Map results to include document data
return results.slice(0, 10).map(result => {
const doc = this.searchDocs.find(d => d.location === result.ref);
if (!doc) return null;
// Extract a snippet around the matched text
const snippet = this.extractSnippet(doc.text, query);
return {
...doc,
score: result.score,
url: `${this.mkdocsUrl}/${doc.location}`,
snippet: snippet
};
}).filter(Boolean);
} catch (error) {
console.error('Search error:', error);
return [];
}
}
extractSnippet(text, query, maxLength = 150) {
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index === -1) {
return text.substring(0, maxLength) + '...';
}
const start = Math.max(0, index - 50);
const end = Math.min(text.length, index + query.length + 100);
let snippet = text.substring(start, end);
if (start > 0) snippet = '...' + snippet;
if (end < text.length) snippet = snippet + '...';
// Highlight the search term
const regex = new RegExp(`(${query})`, 'gi');
snippet = snippet.replace(regex, '<mark>$1</mark>');
return snippet;
}
bindToInput(inputElement, resultsElement) {
const resultsContainer = resultsElement.querySelector('.docs-search-results-list');
const resultsCount = resultsElement.querySelector('.results-count');
const closeBtn = resultsElement.querySelector('.close-results');
// Handle input with debouncing
inputElement.addEventListener('input', (e) => {
clearTimeout(this.debounceTimeout);
this.debounceTimeout = setTimeout(() => {
this.performSearch(e.target.value, resultsContainer, resultsCount, resultsElement);
}, 300);
});
// Handle keyboard shortcuts
inputElement.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closeResults(inputElement, resultsElement);
}
});
// Global keyboard shortcut (Ctrl+K or Cmd+K)
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
inputElement.focus();
inputElement.select();
}
});
// Close button
closeBtn.addEventListener('click', () => {
this.closeResults(inputElement, resultsElement);
});
// Click outside to close
document.addEventListener('click', (e) => {
if (!inputElement.contains(e.target) && !resultsElement.contains(e.target)) {
resultsElement.classList.add('hidden');
}
});
}
performSearch(query, resultsContainer, resultsCount, resultsElement) {
if (!query || query.length < this.minSearchLength) {
resultsElement.classList.add('hidden');
return;
}
const results = this.search(query);
if (results.length === 0) {
resultsContainer.innerHTML = '<div class="no-results">No results found</div>';
resultsCount.textContent = 'No results';
} else {
resultsCount.textContent = `${results.length} result${results.length > 1 ? 's' : ''}`;
resultsContainer.innerHTML = results.map((result, index) => `
<div class="search-result-item">
<a href="${result.url}" class="search-result" target="_blank" rel="noopener">
<div class="search-result-title">${this.escapeHtml(result.title)}</div>
<div class="search-result-snippet">${result.snippet}</div>
<div class="search-result-path">${result.location}</div>
</a>
<button class="btn btn-sm btn-secondary make-qr-btn" data-url="${result.url}" data-index="${index}">
<span class="btn-icon">📱</span>
<span class="btn-text">Make QR</span>
</button>
</div>
`).join('');
// Add event listeners to QR buttons
this.attachQRButtonListeners();
}
resultsElement.classList.remove('hidden');
}
// Add new method to handle QR button clicks
attachQRButtonListeners() {
const qrButtons = document.querySelectorAll('.make-qr-btn');
qrButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const url = button.getAttribute('data-url');
this.showQRCodeModal(url);
});
});
}
// Add method to show QR code modal
showQRCodeModal(url) {
// Create or get existing QR modal
let modal = document.getElementById('qr-code-modal');
if (!modal) {
modal = this.createQRModal();
document.body.appendChild(modal);
}
// Ensure modal has correct z-index and class
modal.classList.add('qr-modal');
modal.style.zIndex = '11000'; // Force above all overlays
// Update modal content
const qrImage = modal.querySelector('.qr-code-image');
const qrUrl = modal.querySelector('.qr-code-url');
const qrLoading = modal.querySelector('.qr-loading');
// Show modal and loading state
modal.classList.remove('hidden');
qrLoading.style.display = 'block';
qrImage.style.display = 'none';
// Update URL display
qrUrl.textContent = url;
qrUrl.href = url;
// Generate QR code
const qrApiUrl = `/api/qr?text=${encodeURIComponent(url)}&size=256`;
qrImage.src = qrApiUrl;
qrImage.onload = () => {
qrLoading.style.display = 'none';
qrImage.style.display = 'block';
};
qrImage.onerror = () => {
qrLoading.innerHTML = '<p style="color: var(--danger-color);">Failed to generate QR code</p>';
};
}
// Add method to create QR modal
createQRModal() {
const modal = document.createElement('div');
modal.id = 'qr-code-modal';
modal.className = 'modal qr-modal'; // Ensure both classes for styling
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" target="_blank" rel="noopener"></a>
</div>
</div>
</div>
`;
// Add close functionality
const closeBtn = modal.querySelector('#close-qr-modal');
closeBtn.addEventListener('click', () => {
modal.classList.add('hidden');
});
// Close on background click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.add('hidden');
}
});
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
modal.classList.add('hidden');
}
});
return modal;
}
closeResults(inputElement, resultsElement) {
resultsElement.classList.add('hidden');
inputElement.value = '';
inputElement.blur();
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}