- Create public/utils.js with shared frontend utility functions - Extract parseUTCDate, getTimeAgo, getTimeRemaining, getRemainingClass to utils.js - Remove duplicate functions from admin.js, app-mapbox.js, app-google.js, and app.js - Add utils.js script import to index.html and admin.html - Add comprehensive JSDoc documentation for all utility functions - Ensure consistent UTC timestamp parsing across all frontend scripts This addresses Copilot AI feedback about function duplication across multiple frontend scripts. Now all timestamp and time calculation logic is centralized in one maintainable module. Benefits: - Single source of truth for time-related utilities - Easier maintenance and updates - Consistent behavior across all frontend components - Better code organization and documentation - Reduced bundle size through deduplication
635 lines
23 KiB
JavaScript
635 lines
23 KiB
JavaScript
document.addEventListener('DOMContentLoaded', async () => {
|
|
const map = L.map('map').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: `
|
|
<div style="
|
|
background-color: ${iconColor};
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 50%;
|
|
border: 3px solid white;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 14px;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
|
|
cursor: pointer;
|
|
">${iconSymbol}</div>
|
|
`,
|
|
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 ? '<br><small><strong>📌 Persistent Report</strong></small>' : '';
|
|
|
|
const marker = L.marker([latitude, longitude], {
|
|
icon: createCustomIcon(isPersistent)
|
|
})
|
|
.addTo(map)
|
|
.bindPopup(`<strong>${address}</strong><br>${description || 'No additional details'}<br><small>Reported ${timeAgo}</small>${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 = '<tr><td colspan="4" class="loading">No active reports</td></tr>';
|
|
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 `
|
|
<tr>
|
|
<td class="location-cell" title="${location.address}">${location.address}</td>
|
|
<td class="details-cell" title="${location.description || 'No additional details'}">
|
|
${location.description || '<em>No additional details</em>'}
|
|
</td>
|
|
<td class="time-cell" title="${reportedTime}">${timeAgo}</td>
|
|
<td class="remaining-cell ${remainingClass}">${timeRemaining}</td>
|
|
</tr>
|
|
`;
|
|
}).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 = '<tr><td colspan="4" class="loading">Error loading reports</td></tr>';
|
|
}
|
|
});
|
|
};
|
|
|
|
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) {
|
|
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;
|
|
}
|
|
}
|