document.addEventListener('DOMContentLoaded', async () => { const map = L.map('map', { scrollWheelZoom: false }).setView([42.9634, -85.6681], 10); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18, }).addTo(map); // Get API configuration let config = { hasMapbox: false, mapboxAccessToken: null }; try { const configResponse = await fetch('/api/config'); config = await configResponse.json(); console.log('🔧 API Configuration:', config.hasMapbox ? 'MapBox enabled' : 'Using Nominatim fallback'); } catch (error) { console.warn('Failed to load API configuration, using fallback'); } const locationForm = document.getElementById('location-form'); const messageDiv = document.getElementById('message'); const submitBtn = document.getElementById('submit-btn'); const submitText = document.getElementById('submit-text'); const submitLoading = document.getElementById('submit-loading'); const addressInput = document.getElementById('address'); const autocompleteList = document.getElementById('autocomplete-list'); // View toggle elements const mapViewBtn = document.getElementById('map-view-btn'); const tableViewBtn = document.getElementById('table-view-btn'); const mapView = document.getElementById('map-view'); const tableView = document.getElementById('table-view'); const reportsTableBody = document.getElementById('reports-tbody'); const tableLocationCount = document.getElementById('table-location-count'); let autocompleteTimeout; let selectedIndex = -1; let autocompleteResults = []; let currentMarkers = []; let updateInterval; let currentLocations = []; let currentView = 'map'; // 'map' or 'table' const clearMarkers = () => { currentMarkers.forEach(marker => { map.removeLayer(marker); }); currentMarkers = []; }; // Create custom icons for different report types const createCustomIcon = (isPersistent = false) => { const iconColor = isPersistent ? '#28a745' : '#dc3545'; // Green for persistent, red for temporary const iconSymbol = isPersistent ? '🔒' : '⚠️'; // Lock for persistent, warning for temporary return L.divIcon({ className: 'custom-marker', html: `
${iconSymbol}
`, iconSize: [30, 30], iconAnchor: [15, 15], popupAnchor: [0, -15] }); }; const showMarkers = locations => { clearMarkers(); locations.forEach(({ latitude, longitude, address, description, created_at, persistent }) => { if (latitude && longitude) { const timeAgo = getTimeAgo(created_at); const isPersistent = !!persistent; const persistentText = isPersistent ? '
📌 Persistent Report' : ''; const marker = L.marker([latitude, longitude], { icon: createCustomIcon(isPersistent) }) .addTo(map) .bindPopup(`${address}
${description || 'No additional details'}
Reported ${timeAgo}${persistentText}`); currentMarkers.push(marker); } }); }; // getTimeAgo function is now available from utils.js // Toggle between map and table view const switchView = (viewType) => { currentView = viewType; if (viewType === 'map') { mapView.style.display = 'block'; tableView.style.display = 'none'; mapViewBtn.classList.add('active'); tableViewBtn.classList.remove('active'); // Invalidate map size after showing setTimeout(() => map.invalidateSize(), 100); } else { mapView.style.display = 'none'; tableView.style.display = 'block'; mapViewBtn.classList.remove('active'); tableViewBtn.classList.add('active'); // Render table with current data renderTable(currentLocations); } }; // Render table view const renderTable = (locations) => { if (!locations || locations.length === 0) { reportsTableBody.innerHTML = 'No active reports'; tableLocationCount.textContent = '0 active reports'; return; } // Sort by most recent first const sortedLocations = [...locations].sort((a, b) => new Date(b.created_at) - new Date(a.created_at) ); const tableHTML = sortedLocations.map(location => { const timeAgo = getTimeAgo(location.created_at); const timeRemaining = getTimeRemaining(location.created_at, location.persistent); const remainingClass = getRemainingClass(location.created_at, location.persistent); const reportedTime = new Date(location.created_at).toLocaleString(); return ` ${location.address} ${location.description || 'No additional details'} ${timeAgo} ${timeRemaining} `; }).join(''); reportsTableBody.innerHTML = tableHTML; tableLocationCount.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`; }; // getTimeRemaining and getRemainingClass functions are now available from utils.js const refreshLocations = () => { fetch('/api/locations') .then(res => res.json()) .then(locations => { currentLocations = locations; // Update map view showMarkers(locations); const countElement = document.getElementById('location-count'); countElement.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`; // Update table view if it's currently visible if (currentView === 'table') { renderTable(locations); } const now = new Date(); const timeStr = now.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); countElement.title = `Last updated: ${timeStr}`; }) .catch(err => { console.error('Error fetching locations:', err); document.getElementById('location-count').textContent = 'Error loading locations'; if (currentView === 'table') { reportsTableBody.innerHTML = 'Error loading reports'; } }); }; refreshLocations(); updateInterval = setInterval(refreshLocations, 30000); // MapBox Geocoding (Fast and Accurate!) const mapboxGeocode = async (address) => { const startTime = performance.now(); console.log(`🚀 MapBox Geocoding: "${address}"`); try { const encodedAddress = encodeURIComponent(address); const response = await fetch( `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedAddress}.json?` + `access_token=${config.mapboxAccessToken}&` + `country=US&` + `region=MI&` + `proximity=-85.6681,42.9634&` + // Grand Rapids coordinates `limit=1` ); if (!response.ok) { throw new Error(`MapBox API error: ${response.status}`); } const data = await response.json(); const time = (performance.now() - startTime).toFixed(2); if (data.features && data.features.length > 0) { const result = data.features[0]; console.log(`✅ MapBox geocoding successful in ${time}ms`); console.log(`📍 Found: ${result.place_name}`); return { lat: result.center[1], // MapBox returns [lng, lat] lon: result.center[0], display_name: result.place_name }; } else { console.log(`❌ MapBox geocoding: no results (${time}ms)`); throw new Error('No results found'); } } catch (error) { const time = (performance.now() - startTime).toFixed(2); console.warn(`🚫 MapBox geocoding failed (${time}ms):`, error); throw error; } }; // Nominatim Fallback (Slower but free) const nominatimGeocode = async (address) => { const startTime = performance.now(); console.log(`🐌 Nominatim fallback: "${address}"`); const searches = [ address, `${address}, Michigan`, `${address}, MI`, `${address}, Grand Rapids, MI` ]; for (const query of searches) { try { const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1&countrycodes=us`); const data = await response.json(); if (data && data.length > 0) { const time = (performance.now() - startTime).toFixed(2); console.log(`✅ Nominatim successful in ${time}ms`); return data[0]; } } catch (error) { console.warn(`Nominatim query failed: ${query}`); } } const time = (performance.now() - startTime).toFixed(2); console.error(`💥 All geocoding failed after ${time}ms`); throw new Error('Address not found'); }; // Main geocoding function const geocodeAddress = async (address) => { if (config.hasMapbox) { try { return await mapboxGeocode(address); } catch (error) { console.warn('MapBox geocoding failed, trying Nominatim fallback'); return await nominatimGeocode(address); } } else { return await nominatimGeocode(address); } }; // MapBox Autocomplete (Lightning Fast!) const mapboxAutocomplete = async (query) => { const startTime = performance.now(); console.log(`⚡ MapBox Autocomplete: "${query}"`); try { const encodedQuery = encodeURIComponent(query); const response = await fetch( `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedQuery}.json?` + `access_token=${config.mapboxAccessToken}&` + `country=US&` + `region=MI&` + `proximity=-85.6681,42.9634&` + // Grand Rapids coordinates `types=address,poi&` + `autocomplete=true&` + `limit=5` ); if (!response.ok) { throw new Error(`MapBox API error: ${response.status}`); } const data = await response.json(); const time = (performance.now() - startTime).toFixed(2); if (data.features && data.features.length > 0) { // Filter for Michigan results const michiganResults = data.features.filter(feature => feature.place_name.toLowerCase().includes('michigan') || feature.place_name.toLowerCase().includes(', mi,') || feature.context?.some(ctx => ctx.short_code === 'us-mi') ); console.log(`⚡ MapBox autocomplete: ${michiganResults.length} results in ${time}ms`); return michiganResults.slice(0, 5).map(feature => ({ display_name: feature.place_name, mapbox_id: feature.id })); } else { console.log(`❌ MapBox autocomplete: no results (${time}ms)`); return []; } } catch (error) { const time = (performance.now() - startTime).toFixed(2); console.warn(`🚫 MapBox autocomplete failed (${time}ms):`, error); return []; } }; // Nominatim Autocomplete Fallback const nominatimAutocomplete = async (query) => { console.log(`🐌 Nominatim autocomplete fallback: "${query}"`); try { const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&countrycodes=us&addressdetails=1`); const data = await response.json(); return data.filter(item => item.display_name.toLowerCase().includes('michigan') || item.display_name.toLowerCase().includes(', mi,') ).slice(0, 5); } catch (error) { console.error('Nominatim autocomplete failed:', error); return []; } }; // Main autocomplete function const fetchAddressSuggestions = async (query) => { if (query.length < 3) { hideAutocomplete(); return; } let results = []; if (config.hasMapbox) { try { results = await mapboxAutocomplete(query); } catch (error) { console.warn('MapBox autocomplete failed, trying Nominatim'); } } if (results.length === 0) { results = await nominatimAutocomplete(query); } autocompleteResults = results; showAutocomplete(autocompleteResults); }; // Form submission locationForm.addEventListener('submit', e => { e.preventDefault(); const address = document.getElementById('address').value; const description = document.getElementById('description').value; if (!address) return; submitBtn.disabled = true; submitText.style.display = 'none'; submitLoading.style.display = 'inline'; geocodeAddress(address) .then(result => { const { lat, lon } = result; return fetch('/api/locations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address, latitude: parseFloat(lat), longitude: parseFloat(lon), description, }), }); }) .then(res => { if (!res.ok) { return res.json().then(errorData => { throw { status: res.status, data: errorData }; }); } return res.json(); }) .then(location => { refreshLocations(); messageDiv.textContent = 'Location reported successfully!'; messageDiv.className = 'message success'; locationForm.reset(); }) .catch(err => { console.error('Error reporting location:', err); // Handle specific profanity rejection if (err.status === 400 && err.data && err.data.error === 'Submission rejected') { messageDiv.textContent = err.data.message; messageDiv.className = 'message error profanity-rejection'; // Clear the description field to encourage rewriting document.getElementById('description').value = ''; document.getElementById('description').focus(); } else if (err.data && err.data.error) { messageDiv.textContent = err.data.error; messageDiv.className = 'message error'; } else { messageDiv.textContent = 'Error reporting location. Please try again.'; messageDiv.className = 'message error'; } }) .finally(() => { submitBtn.disabled = false; submitText.style.display = 'inline'; submitLoading.style.display = 'none'; messageDiv.style.display = 'block'; // Longer timeout for profanity rejection messages const timeout = messageDiv.className.includes('profanity-rejection') ? 15000 : 3000; setTimeout(() => { messageDiv.style.display = 'none'; }, timeout); }); }); // Autocomplete UI functions const showAutocomplete = (results) => { if (results.length === 0) { hideAutocomplete(); return; } autocompleteList.innerHTML = ''; selectedIndex = -1; results.forEach((result, index) => { const item = document.createElement('div'); item.className = 'autocomplete-item'; item.textContent = result.display_name; item.addEventListener('click', () => selectAddress(result)); autocompleteList.appendChild(item); }); autocompleteList.style.display = 'block'; }; const hideAutocomplete = () => { autocompleteList.style.display = 'none'; selectedIndex = -1; }; const selectAddress = (result) => { addressInput.value = result.display_name; hideAutocomplete(); addressInput.focus(); }; const updateSelection = (direction) => { const items = autocompleteList.querySelectorAll('.autocomplete-item'); if (items.length === 0) return; items[selectedIndex]?.classList.remove('selected'); if (direction === 'down') { selectedIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0; } else { selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1; } items[selectedIndex].classList.add('selected'); items[selectedIndex].scrollIntoView({ block: 'nearest' }); }; // Event listeners addressInput.addEventListener('input', (e) => { clearTimeout(autocompleteTimeout); const query = e.target.value.trim(); if (query.length < 3) { hideAutocomplete(); return; } // Ultra-fast debounce with MapBox autocompleteTimeout = setTimeout(() => { fetchAddressSuggestions(query); }, config.hasMapbox ? 150 : 300); }); addressInput.addEventListener('keydown', (e) => { const isListVisible = autocompleteList.style.display === 'block'; switch (e.key) { case 'ArrowDown': if (isListVisible) { e.preventDefault(); updateSelection('down'); } break; case 'ArrowUp': if (isListVisible) { e.preventDefault(); updateSelection('up'); } break; case 'Enter': if (isListVisible && selectedIndex >= 0) { e.preventDefault(); selectAddress(autocompleteResults[selectedIndex]); } break; case 'Escape': hideAutocomplete(); break; } }); document.addEventListener('click', (e) => { if (!e.target.closest('.autocomplete-container')) { hideAutocomplete(); } }); // View toggle event listeners mapViewBtn.addEventListener('click', () => switchView('map')); tableViewBtn.addEventListener('click', () => switchView('table')); window.addEventListener('beforeunload', () => { if (updateInterval) { clearInterval(updateInterval); } }); // Initialize theme toggle initializeTheme(); }); // Theme toggle functionality function initializeTheme() { const themeToggle = document.getElementById('theme-toggle'); const themeIcon = document.querySelector('.theme-icon'); if (!themeToggle || !themeIcon) { console.warn('Theme toggle elements not found'); return; } // Check for saved theme preference or default to auto (follows system) const savedTheme = localStorage.getItem('theme') || 'auto'; applyTheme(savedTheme); // Update icon based on current theme updateThemeIcon(savedTheme, themeIcon); // Add click listener for cycling through themes themeToggle.addEventListener('click', () => { const currentTheme = localStorage.getItem('theme') || 'auto'; let newTheme; // Cycle: auto → light → dark → auto switch(currentTheme) { case 'auto': newTheme = 'light'; break; case 'light': newTheme = 'dark'; break; case 'dark': newTheme = 'auto'; break; default: newTheme = 'auto'; } localStorage.setItem('theme', newTheme); applyTheme(newTheme); updateThemeIcon(newTheme, themeIcon); console.log(`Theme switched to: ${newTheme}`); }); // Listen for system theme changes when in auto mode window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { const currentTheme = localStorage.getItem('theme') || 'auto'; if (currentTheme === 'auto') { applyTheme('auto'); } }); } function applyTheme(theme) { if (theme === 'auto') { // Detect system preference and apply appropriate theme const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); } else { document.documentElement.setAttribute('data-theme', theme); } } function updateThemeIcon(theme, iconElement) { switch(theme) { case 'auto': iconElement.textContent = '🌍'; // Globe (auto) break; case 'light': iconElement.textContent = '☀️'; // Sun (light) break; case 'dark': iconElement.textContent = '🌙'; // Moon (dark) break; } }