313 lines
9.5 KiB
JavaScript
313 lines
9.5 KiB
JavaScript
/**
|
|
* 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; |