A tonne more changes, including new nocodb admin section, search for database, code cleanups, and debugging

This commit is contained in:
2025-08-01 15:01:35 -06:00
parent a1ef498309
commit cbf359941b
18 changed files with 2780 additions and 114 deletions

View File

@@ -31,6 +31,7 @@ document.addEventListener('DOMContentLoaded', () => {
setAdminViewportDimensions();
window.addEventListener('resize', setAdminViewportDimensions);
window.addEventListener('orientationchange', () => {
// Add a small delay for orientation change to complete
setTimeout(setAdminViewportDimensions, 100);
});
@@ -39,36 +40,24 @@ document.addEventListener('DOMContentLoaded', () => {
loadCurrentStartLocation();
setupEventListeners();
setupNavigation();
setupMobileMenu(); // Add this line
setupMobileMenu();
// Initialize NocoDB links with a small delay to ensure DOM is ready
setTimeout(() => {
loadWalkSheetConfig();
initializeNocodbLinks();
}, 100);
// Check if URL has a hash to show specific section
const hash = window.location.hash;
if (hash === '#walk-sheet') {
// Show walk sheet section and load config
const startLocationSection = document.getElementById('start-location');
const walkSheetSection = document.getElementById('walk-sheet');
const walkSheetNav = document.querySelector('.admin-nav a[href="#walk-sheet"]');
const startLocationNav = document.querySelector('.admin-nav a[href="#start-location"]');
if (startLocationSection) startLocationSection.style.display = 'none';
if (walkSheetSection) walkSheetSection.style.display = 'block';
if (startLocationNav) startLocationNav.classList.remove('active');
if (walkSheetNav) walkSheetNav.classList.add('active');
// Load walk sheet config
setTimeout(() => {
loadWalkSheetConfig().then((success) => {
if (success) {
generateWalkSheetPreview();
}
});
}, 200);
showSection('walk-sheet');
checkAndLoadWalkSheetConfig();
} else {
// Even if not showing walk sheet section, load the config so it's available
// This ensures the config is loaded when the page loads, just like map location
setTimeout(() => {
loadWalkSheetConfig();
}, 300);
// Default to dashboard
showSection('dashboard');
// Load dashboard data on initial page load
loadDashboardData();
}
});
@@ -126,11 +115,16 @@ async function checkAdminAuth() {
const response = await fetch('/api/auth/check');
const data = await response.json();
console.log('Admin auth check result:', data);
if (!data.authenticated || !data.user?.isAdmin) {
console.log('Redirecting to login - not authenticated or not admin');
window.location.href = '/login.html';
return;
}
console.log('User is authenticated as admin:', data.user);
// Display admin info (desktop)
document.getElementById('admin-info').innerHTML = `
<span>👤 ${escapeHtml(data.user.email)}</span>
@@ -373,73 +367,71 @@ function setupNavigation() {
link.addEventListener('click', (e) => {
e.preventDefault();
// Get target section ID
const targetId = link.getAttribute('href').substring(1);
// Hide all sections
sections.forEach(section => {
section.style.display = 'none';
});
// Show target section
const targetSection = document.getElementById(targetId);
if (targetSection) {
targetSection.style.display = 'block';
}
// Update active nav link
navLinks.forEach(navLink => {
navLink.classList.remove('active');
});
// Update active nav
navLinks.forEach(l => l.classList.remove('active'));
link.classList.add('active');
// If switching to shifts section, load shifts
if (targetId === 'shifts') {
console.log('Loading admin shifts...');
loadAdminShifts();
}
// Show target section
sections.forEach(section => {
section.style.display = section.id === targetId ? 'block' : 'none';
});
// If switching to users section, load users
if (targetId === 'users') {
console.log('Loading users...');
// Update URL hash
window.location.hash = targetId;
// Load section-specific data
if (targetId === 'walk-sheet') {
checkAndLoadWalkSheetConfig();
} else if (targetId === 'dashboard') {
loadDashboardData();
} else if (targetId === 'shifts') {
loadAdminShifts();
} else if (targetId === 'users') {
loadUsers();
}
// If switching to walk sheet section, load config
if (targetId === 'walk-sheet') {
loadWalkSheetConfig().then((success) => {
if (success) {
generateWalkSheetPreview();
}
});
}
// If switching to convert-data section, ensure event listeners are set up
if (targetId === 'convert-data') {
console.log('Convert Data section activated');
// Initialize data convert functionality if available
setTimeout(() => {
if (typeof window.setupDataConvertEventListeners === 'function') {
console.log('Setting up data convert event listeners...');
window.setupDataConvertEventListeners();
} else {
console.warn('setupDataConvertEventListeners function not available');
}
}, 100); // Small delay to ensure DOM is ready
// Close mobile menu if open
const sidebar = document.getElementById('admin-sidebar');
if (sidebar && sidebar.classList.contains('open')) {
sidebar.classList.remove('open');
}
});
});
// Set initial active state based on current hash or default
const currentHash = window.location.hash || '#dashboard';
const activeLink = document.querySelector(`.admin-nav a[href="${currentHash}"]`);
if (activeLink) {
activeLink.classList.add('active');
}
// Also check if we're already on the shifts page (via hash)
const hash = window.location.hash;
if (hash === '#shifts') {
const shiftsLink = document.querySelector('.admin-nav a[href="#shifts"]');
if (shiftsLink) {
shiftsLink.click();
}
showSection('shifts');
loadAdminShifts();
}
}
// Helper function to show a specific section
function showSection(sectionId) {
const sections = document.querySelectorAll('.admin-section');
const navLinks = document.querySelectorAll('.admin-nav a');
// Hide all sections
sections.forEach(section => {
section.style.display = section.id === sectionId ? 'block' : 'none';
});
// Update active nav
navLinks.forEach(link => {
const linkTarget = link.getAttribute('href').substring(1);
link.classList.toggle('active', linkTarget === sectionId);
});
}
// Update map from input fields
function updateMapFromInputs() {
const lat = parseFloat(document.getElementById('start-lat').value);
@@ -1396,3 +1388,85 @@ function clearUserForm() {
showStatus('User form cleared', 'info');
}
}
// Initialize NocoDB links in admin panel
async function initializeNocodbLinks() {
console.log('Starting NocoDB links initialization...');
try {
// Since we're in the admin panel, the user is already verified as admin
// by the requireAdmin middleware. Let's get the URLs from the server directly.
console.log('Fetching NocoDB URLs for admin panel...');
const configResponse = await fetch('/api/admin/nocodb-urls');
if (!configResponse.ok) {
throw new Error(`NocoDB URLs fetch failed: ${configResponse.status} ${configResponse.statusText}`);
}
const config = await configResponse.json();
console.log('NocoDB URLs received:', config);
if (config.success && config.nocodbUrls) {
console.log('Setting up NocoDB links with URLs:', config.nocodbUrls);
// Set up admin dashboard NocoDB links
setAdminNocodbLink('admin-nocodb-view-link', config.nocodbUrls.viewUrl);
setAdminNocodbLink('admin-nocodb-login-link', config.nocodbUrls.loginSheet);
setAdminNocodbLink('admin-nocodb-settings-link', config.nocodbUrls.settingsSheet);
setAdminNocodbLink('admin-nocodb-shifts-link', config.nocodbUrls.shiftsSheet);
setAdminNocodbLink('admin-nocodb-signups-link', config.nocodbUrls.shiftSignupsSheet);
console.log('NocoDB links initialized in admin panel');
} else {
console.warn('No NocoDB URLs found in admin config response');
// Hide the NocoDB section if no URLs are available
const nocodbSection = document.getElementById('nocodb-links');
const nocodbNav = document.querySelector('.admin-nav a[href="#nocodb-links"]');
if (nocodbSection) {
nocodbSection.style.display = 'none';
console.log('Hidden NocoDB section');
}
if (nocodbNav) {
nocodbNav.style.display = 'none';
console.log('Hidden NocoDB nav link');
}
}
} catch (error) {
console.error('Error initializing NocoDB links in admin panel:', error);
// Hide the NocoDB section on error
const nocodbSection = document.getElementById('nocodb-links');
const nocodbNav = document.querySelector('.admin-nav a[href="#nocodb-links"]');
if (nocodbSection) {
nocodbSection.style.display = 'none';
console.log('Hidden NocoDB section due to error');
}
if (nocodbNav) {
nocodbNav.style.display = 'none';
console.log('Hidden NocoDB nav link due to error');
}
}
}
// Helper function to set admin NocoDB link href
function setAdminNocodbLink(elementId, url) {
console.log(`Setting up NocoDB link: ${elementId} = ${url}`);
const element = document.getElementById(elementId);
if (element && url) {
element.href = url;
element.style.display = 'inline-flex';
// Remove any disabled state
element.classList.remove('btn-disabled');
element.removeAttribute('disabled');
console.log(`✓ Successfully set up ${elementId}`);
} else if (element) {
element.style.display = 'none';
// Add disabled state if no URL
element.classList.add('btn-disabled');
element.setAttribute('disabled', 'disabled');
element.href = '#';
console.log(`⚠ Disabled ${elementId} - no URL provided`);
} else {
console.error(`✗ Element not found: ${elementId}`);
}
}

View File

@@ -0,0 +1,313 @@
/**
* Database Search Module
* Handles location search functionality through loaded map data
*/
import { map } from './map-manager.js';
import { markers } from './location-manager.js';
import { openEditForm } from './location-manager.js';
export class DatabaseSearch {
constructor() {
this.searchCache = new Map();
}
/**
* Search through loaded locations
* @param {string} query - The search query
* @returns {Promise<Array>} Array of search results
*/
async search(query) {
if (!query || query.trim().length < 2) {
return [];
}
const trimmedQuery = query.trim().toLowerCase();
// Check cache first
if (this.searchCache.has(trimmedQuery)) {
return this.searchCache.get(trimmedQuery);
}
try {
// Get all locations from loaded markers
const locations = this.getLoadedLocations();
// Filter locations based on search query
const results = locations.filter(location => {
return this.matchesQuery(location, trimmedQuery);
}).map(location => {
return this.formatResult(location, trimmedQuery);
}).slice(0, 10); // Limit to 10 results
// Cache the results
this.searchCache.set(trimmedQuery, results);
// Clean up cache if it gets too large
if (this.searchCache.size > 50) {
const firstKey = this.searchCache.keys().next().value;
this.searchCache.delete(firstKey);
}
return results;
} catch (error) {
console.error('Database search error:', error);
throw error;
}
}
/**
* Get all loaded location data from markers
* @returns {Array} Array of location objects
*/
getLoadedLocations() {
const locations = [];
markers.forEach(marker => {
if (marker._locationData) {
locations.push(marker._locationData);
}
});
return locations;
}
/**
* Check if a location matches the search query
* @param {Object} location - Location object
* @param {string} query - Search query (lowercase)
* @returns {boolean} Whether the location matches
*/
matchesQuery(location, query) {
const searchFields = [
location['First Name'],
location['Last Name'],
location.Email,
location.Phone,
location.Address,
location['Unit Number'],
location.Notes
];
// Combine first and last name
const fullName = [location['First Name'], location['Last Name']]
.filter(Boolean).join(' ').toLowerCase();
return searchFields.some(field => {
if (!field) return false;
return String(field).toLowerCase().includes(query);
}) || fullName.includes(query);
}
/**
* Format a location for search results
* @param {Object} location - Location object
* @param {string} query - Search query for highlighting
* @returns {Object} Formatted result
*/
formatResult(location, query) {
const name = [location['First Name'], location['Last Name']]
.filter(Boolean).join(' ') || 'Unknown';
const address = location.Address || 'No address';
const email = location.Email || '';
const phone = location.Phone || '';
const unit = location['Unit Number'] || '';
const supportLevel = location['Support Level'] || '';
const notes = location.Notes || '';
// Create a snippet with highlighted matches
const snippet = this.createSnippet(location, query);
return {
id: location.Id || location.id || location.ID || location._id,
name,
address,
email,
phone,
unit,
supportLevel,
notes,
snippet,
coordinates: {
lat: parseFloat(location.latitude) || 0,
lng: parseFloat(location.longitude) || 0
},
location: location // Keep full location data for actions
};
}
/**
* Create a text snippet with highlighted matches
* @param {Object} location - Location object
* @param {string} query - Search query
* @returns {string} Snippet with highlights
*/
createSnippet(location, query) {
const searchableText = [
location['First Name'],
location['Last Name'],
location.Email,
location.Address,
location['Unit Number'],
location.Notes
].filter(Boolean).join(' • ');
if (searchableText.length <= 100) {
return this.highlightQuery(searchableText, query);
}
// Find the first occurrence of the query
const lowerText = searchableText.toLowerCase();
const index = lowerText.indexOf(query);
if (index === -1) {
// Return first 100 characters if no match
return searchableText.substring(0, 100) + (searchableText.length > 100 ? '...' : '');
}
// Extract snippet around the match
const start = Math.max(0, index - 30);
const end = Math.min(searchableText.length, start + 100);
let snippet = searchableText.substring(start, end);
// Add ellipsis if needed
if (start > 0) snippet = '...' + snippet;
if (end < searchableText.length) snippet = snippet + '...';
return this.highlightQuery(snippet, query);
}
/**
* Highlight search query in text
* @param {string} text - Text to highlight
* @param {string} query - Query to highlight
* @returns {string} Text with highlights
*/
highlightQuery(text, query) {
if (!query || !text) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
/**
* Create HTML for a search result
* @param {Object} result - Search result object
* @returns {HTMLElement} Result element
*/
createResultElement(result) {
const resultEl = document.createElement('div');
resultEl.className = 'search-result-item search-result-database';
const supportLevelText = result.supportLevel ? `Level ${result.supportLevel}` : '';
const unitText = result.unit ? `Unit ${result.unit}` : '';
resultEl.innerHTML = `
<div class="result-name">${this.escapeHtml(result.name)}</div>
<div class="result-address">${this.escapeHtml(result.address)} ${this.escapeHtml(unitText)}</div>
<div class="result-snippet">${result.snippet}</div>
<div class="result-details">
${result.email ? `📧 ${this.escapeHtml(result.email)}` : ''}
${result.phone ? ` 📞 ${this.escapeHtml(result.phone)}` : ''}
${supportLevelText ? ` 🎯 ${supportLevelText}` : ''}
</div>
`;
resultEl.addEventListener('click', () => {
this.selectResult(result);
});
return resultEl;
}
/**
* Handle selection of a search result
* @param {Object} result - Selected result
*/
selectResult(result) {
if (!map) {
console.error('Map not available');
return;
}
const { lat, lng } = result.coordinates;
if (isNaN(lat) || isNaN(lng)) {
console.error('Invalid coordinates in result:', result);
return;
}
// Pan and zoom to the location
map.setView([lat, lng], 17);
// Find and open the marker popup
const marker = markers.find(m => {
if (!m._locationData) return false;
const markerId = m._locationData.Id || m._locationData.id || m._locationData.ID || m._locationData._id;
return markerId == result.id;
});
if (marker) {
// Open the popup
marker.openPopup();
// Optionally highlight the marker temporarily
this.highlightMarker(marker);
}
}
/**
* Temporarily highlight a marker
* @param {Object} marker - Leaflet marker
*/
highlightMarker(marker) {
if (!marker || !marker.setStyle) return;
const originalStyle = {
fillColor: marker.options.fillColor,
color: marker.options.color,
weight: marker.options.weight,
radius: marker.options.radius
};
// Highlight style
marker.setStyle({
fillColor: '#FFD700',
color: '#FF6B35',
weight: 4,
radius: 12
});
// Restore original style after 3 seconds
setTimeout(() => {
marker.setStyle(originalStyle);
}, 3000);
}
/**
* Escape HTML to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
/**
* Clear the search cache
*/
clearCache() {
this.searchCache.clear();
}
}
// Create a global instance
window.databaseSearchInstance = new DatabaseSearch();
export default window.databaseSearchInstance;

View File

@@ -5,11 +5,11 @@ import { checkAuth } from './auth.js';
import { initializeMap } from './map-manager.js';
import { loadLocations } from './location-manager.js';
import { setupEventListeners } from './ui-controls.js';
import { MkDocsSearch } from './mkdocs-search.js';
import { UnifiedSearchManager } from './search-manager.js';
// Application state
let refreshInterval = null;
let mkdocsSearch = null;
let unifiedSearchManager = null;
// Initialize the application
document.addEventListener('DOMContentLoaded', async () => {
@@ -40,8 +40,8 @@ document.addEventListener('DOMContentLoaded', async () => {
setupEventListeners();
setupAutoRefresh();
// Initialize MkDocs search
await initializeMkDocsSearch();
// Initialize Unified Search
await initializeUnifiedSearch();
} catch (error) {
console.error('Initialization error:', error);
@@ -64,33 +64,38 @@ window.addEventListener('beforeunload', () => {
}
});
// Initialize MkDocs search
async function initializeMkDocsSearch() {
// Initialize Unified Search
async function initializeUnifiedSearch() {
try {
// Get config from server
const configResponse = await fetch('/api/config');
const config = await configResponse.json();
mkdocsSearch = new MkDocsSearch({
unifiedSearchManager = new UnifiedSearchManager({
mkdocsUrl: config.mkdocsUrl || 'http://localhost:4002',
minSearchLength: 2
});
const initialized = await mkdocsSearch.initialize();
const initialized = await unifiedSearchManager.initialize();
if (initialized) {
// Bind to search input
const searchInput = document.getElementById('docs-search-input');
const searchResults = document.getElementById('docs-search-results');
// Bind to search container
const searchContainer = document.querySelector('.unified-search-container');
if (searchInput && searchResults) {
mkdocsSearch.bindToInput(searchInput, searchResults);
console.log('Documentation search ready');
if (searchContainer) {
const bound = unifiedSearchManager.bindToElements(searchContainer);
if (bound) {
console.log('Unified search ready');
} else {
console.warn('Failed to bind unified search to elements');
}
} else {
console.warn('Unified search container not found');
}
} else {
console.warn('Documentation search could not be initialized');
console.warn('Unified search could not be initialized');
}
} catch (error) {
console.error('Error setting up documentation search:', error);
console.error('Error setting up unified search:', error);
}
}

View File

@@ -0,0 +1,196 @@
/**
* Map Search Module
* Handles address search functionality for the map
*/
import { map } from './map-manager.js';
import { openAddModal } from './location-manager.js';
export class MapSearch {
constructor() {
this.searchCache = new Map();
this.tempMarker = null;
}
/**
* Search for addresses using the geocoding API
* @param {string} query - The search query
* @returns {Promise<Array>} Array of search results
*/
async search(query) {
if (!query || query.trim().length < 2) {
return [];
}
const trimmedQuery = query.trim();
// Check cache first
if (this.searchCache.has(trimmedQuery)) {
return this.searchCache.get(trimmedQuery);
}
try {
const response = await fetch(`/api/geocode/search?query=${encodeURIComponent(trimmedQuery)}&limit=5`);
if (!response.ok) {
throw new Error(`Search failed: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Search failed');
}
const results = data.data || [];
// Cache the results
this.searchCache.set(trimmedQuery, results);
// Clean up cache if it gets too large
if (this.searchCache.size > 100) {
const firstKey = this.searchCache.keys().next().value;
this.searchCache.delete(firstKey);
}
return results;
} catch (error) {
console.error('Map search error:', error);
throw error;
}
}
/**
* Create HTML for a search result
* @param {Object} result - Search result object
* @returns {HTMLElement} Result element
*/
createResultElement(result) {
const resultEl = document.createElement('div');
resultEl.className = 'search-result-item search-result-map';
// Handle both coordinate formats for compatibility
const lat = result.coordinates?.lat || result.latitude || 0;
const lng = result.coordinates?.lng || result.longitude || 0;
// Debugging - log result structure if coordinates are missing
if (!lat && !lng) {
console.warn('Search result missing coordinates:', result);
}
resultEl.innerHTML = `
<div class="result-address">${result.formattedAddress || 'Unknown Address'}</div>
<div class="result-full-address">${result.fullAddress || ''}</div>
<div class="result-coordinates">${lat.toFixed(6)}, ${lng.toFixed(6)}</div>
`;
resultEl.addEventListener('click', () => {
this.selectResult(result);
});
return resultEl;
}
/**
* Handle selection of a search result
* @param {Object} result - Selected result
*/
selectResult(result) {
if (!map) {
console.error('Map not available');
return;
}
// Handle both coordinate formats for compatibility
const lat = parseFloat(result.coordinates?.lat || result.latitude || 0);
const lng = parseFloat(result.coordinates?.lng || result.longitude || 0);
if (isNaN(lat) || isNaN(lng)) {
console.error('Invalid coordinates in result:', result);
return;
}
// Pan and zoom to the location
map.setView([lat, lng], 16);
// Remove any existing temporary marker
this.clearTempMarker();
// Add a temporary marker
this.tempMarker = L.marker([lat, lng], {
icon: L.divIcon({
className: 'temp-search-marker',
html: '📍',
iconSize: [30, 30],
iconAnchor: [15, 30]
})
}).addTo(map);
// Create popup with add location option
const popupContent = `
<div class="search-result-popup">
<h3>${result.formattedAddress || 'Search Result'}</h3>
<p>${result.fullAddress || ''}</p>
<div class="popup-actions">
<button class="btn btn-success btn-sm" onclick="mapSearchInstance.openAddLocationModal(${lat}, ${lng})">
Add Location Here
</button>
<button class="btn btn-secondary btn-sm" onclick="mapSearchInstance.clearTempMarker()">
✕ Clear
</button>
</div>
</div>
`;
this.tempMarker.bindPopup(popupContent).openPopup();
// Auto-clear the marker after 30 seconds
setTimeout(() => {
this.clearTempMarker();
}, 30000);
}
/**
* Open the add location modal at specified coordinates
* @param {number} lat - Latitude
* @param {number} lng - Longitude
*/
openAddLocationModal(lat, lng) {
this.clearTempMarker();
if (typeof openAddModal === 'function') {
openAddModal(lat, lng);
} else {
// Fallback: trigger the add location button click
const addBtn = document.getElementById('add-location-btn');
if (addBtn) {
// Set a temporary flag for the coordinates
window.tempSearchCoordinates = { lat, lng };
addBtn.click();
}
}
}
/**
* Clear the temporary search marker
*/
clearTempMarker() {
if (this.tempMarker && map) {
map.removeLayer(this.tempMarker);
this.tempMarker = null;
}
}
/**
* Clear the search cache
*/
clearCache() {
this.searchCache.clear();
}
}
// Create a global instance for use in popup buttons
window.mapSearchInstance = new MapSearch();
export default window.mapSearchInstance;

View File

@@ -0,0 +1,628 @@
/**
* 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>';
};
}
}