A tonne more changes, including new nocodb admin section, search for database, code cleanups, and debugging
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
313
map/app/public/js/database-search.js
Normal file
313
map/app/public/js/database-search.js
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
196
map/app/public/js/map-search.js
Normal file
196
map/app/public/js/map-search.js
Normal 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;
|
||||
628
map/app/public/js/search-manager.js
Normal file
628
map/app/public/js/search-manager.js
Normal 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">×</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>';
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user