Initial commit: ICE Watch Michigan community safety tool
- Node.js/Express backend with SQLite database - Interactive map with real-time location tracking - MapBox API integration for fast geocoding - Admin panel for content moderation - 24-hour auto-expiring reports - Deployment scripts for Debian 12 ARM64 - Caddy reverse proxy with automatic HTTPS
This commit is contained in:
commit
edfdeb5117
16 changed files with 5323 additions and 0 deletions
433
public/app-google.js
Normal file
433
public/app-google.js
Normal file
|
@ -0,0 +1,433 @@
|
|||
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 = { hasGoogleMaps: false, googleMapsApiKey: null };
|
||||
try {
|
||||
const configResponse = await fetch('/api/config');
|
||||
config = await configResponse.json();
|
||||
console.log('🔧 API Configuration:', config.hasGoogleMaps ? 'Google Maps 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');
|
||||
|
||||
let autocompleteTimeout;
|
||||
let selectedIndex = -1;
|
||||
let autocompleteResults = [];
|
||||
let currentMarkers = [];
|
||||
let updateInterval;
|
||||
let googleAutocompleteService = null;
|
||||
let googleGeocoderService = null;
|
||||
|
||||
// Initialize Google Services if API key is available
|
||||
if (config.hasGoogleMaps) {
|
||||
try {
|
||||
// Load Google Maps API
|
||||
await loadGoogleMapsAPI(config.googleMapsApiKey);
|
||||
googleAutocompleteService = new google.maps.places.AutocompleteService();
|
||||
googleGeocoderService = new google.maps.Geocoder();
|
||||
console.log('✅ Google Maps services initialized');
|
||||
} catch (error) {
|
||||
console.warn('❌ Failed to initialize Google Maps, falling back to Nominatim');
|
||||
}
|
||||
}
|
||||
|
||||
function loadGoogleMapsAPI(apiKey) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.google) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
const clearMarkers = () => {
|
||||
currentMarkers.forEach(marker => {
|
||||
map.removeLayer(marker);
|
||||
});
|
||||
currentMarkers = [];
|
||||
};
|
||||
|
||||
const showMarkers = locations => {
|
||||
clearMarkers();
|
||||
|
||||
locations.forEach(({ latitude, longitude, address, description, created_at }) => {
|
||||
if (latitude && longitude) {
|
||||
const timeAgo = getTimeAgo(created_at);
|
||||
const marker = L.marker([latitude, longitude])
|
||||
.addTo(map)
|
||||
.bindPopup(`<strong>${address}</strong><br>${description || 'No additional details'}<br><small>Reported ${timeAgo}</small>`);
|
||||
currentMarkers.push(marker);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getTimeAgo = (timestamp) => {
|
||||
const now = new Date();
|
||||
const reportTime = new Date(timestamp);
|
||||
const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60));
|
||||
|
||||
if (diffInMinutes < 1) return 'just now';
|
||||
if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
|
||||
|
||||
return 'over a day ago';
|
||||
};
|
||||
|
||||
const refreshLocations = () => {
|
||||
fetch('/api/locations')
|
||||
.then(res => res.json())
|
||||
.then(locations => {
|
||||
showMarkers(locations);
|
||||
const countElement = document.getElementById('location-count');
|
||||
countElement.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`;
|
||||
|
||||
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';
|
||||
});
|
||||
};
|
||||
|
||||
refreshLocations();
|
||||
updateInterval = setInterval(refreshLocations, 30000);
|
||||
|
||||
// Google Maps Geocoding (Fast!)
|
||||
const googleGeocode = async (address) => {
|
||||
const startTime = performance.now();
|
||||
console.log(`🚀 Google Geocoding: "${address}"`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
googleGeocoderService.geocode({
|
||||
address: address,
|
||||
componentRestrictions: { country: 'US', administrativeArea: 'MI' },
|
||||
region: 'us'
|
||||
}, (results, status) => {
|
||||
const time = (performance.now() - startTime).toFixed(2);
|
||||
|
||||
if (status === 'OK' && results.length > 0) {
|
||||
const result = results[0];
|
||||
console.log(`✅ Google geocoding successful in ${time}ms`);
|
||||
console.log(`📍 Found: ${result.formatted_address}`);
|
||||
|
||||
resolve({
|
||||
lat: result.geometry.location.lat(),
|
||||
lon: result.geometry.location.lng(),
|
||||
display_name: result.formatted_address
|
||||
});
|
||||
} else {
|
||||
console.log(`❌ Google geocoding failed: ${status} (${time}ms)`);
|
||||
reject(new Error(`Google geocoding failed: ${status}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 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 (googleGeocoderService) {
|
||||
try {
|
||||
return await googleGeocode(address);
|
||||
} catch (error) {
|
||||
console.warn('Google geocoding failed, trying Nominatim fallback');
|
||||
return await nominatimGeocode(address);
|
||||
}
|
||||
} else {
|
||||
return await nominatimGeocode(address);
|
||||
}
|
||||
};
|
||||
|
||||
// Google Places Autocomplete (Super fast!)
|
||||
const googleAutocomplete = async (query) => {
|
||||
const startTime = performance.now();
|
||||
console.log(`⚡ Google Autocomplete: "${query}"`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
googleAutocompleteService.getPlacePredictions({
|
||||
input: query,
|
||||
componentRestrictions: { country: 'us' },
|
||||
types: ['address'],
|
||||
location: new google.maps.LatLng(42.9634, -85.6681),
|
||||
radius: 50000 // 50km around Grand Rapids
|
||||
}, (predictions, status) => {
|
||||
const time = (performance.now() - startTime).toFixed(2);
|
||||
|
||||
if (status === google.maps.places.PlacesServiceStatus.OK && predictions) {
|
||||
const michiganResults = predictions.filter(p =>
|
||||
p.description.toLowerCase().includes('mi,') ||
|
||||
p.description.toLowerCase().includes('michigan')
|
||||
);
|
||||
|
||||
console.log(`⚡ Google autocomplete: ${michiganResults.length} results in ${time}ms`);
|
||||
resolve(michiganResults.slice(0, 5).map(p => ({
|
||||
display_name: p.description,
|
||||
place_id: p.place_id
|
||||
})));
|
||||
} else {
|
||||
console.log(`❌ Google autocomplete failed: ${status} (${time}ms)`);
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 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 (googleAutocompleteService) {
|
||||
try {
|
||||
results = await googleAutocomplete(query);
|
||||
} catch (error) {
|
||||
console.warn('Google 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 => res.json())
|
||||
.then(location => {
|
||||
refreshLocations();
|
||||
messageDiv.textContent = 'Location reported successfully!';
|
||||
messageDiv.className = 'message success';
|
||||
locationForm.reset();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error reporting location:', err);
|
||||
messageDiv.textContent = 'Error reporting location.';
|
||||
messageDiv.className = 'message error';
|
||||
})
|
||||
.finally(() => {
|
||||
submitBtn.disabled = false;
|
||||
submitText.style.display = 'inline';
|
||||
submitLoading.style.display = 'none';
|
||||
messageDiv.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = 'none';
|
||||
}, 3000);
|
||||
});
|
||||
});
|
||||
|
||||
// Autocomplete UI functions (same as before)
|
||||
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;
|
||||
}
|
||||
|
||||
autocompleteTimeout = setTimeout(() => {
|
||||
fetchAddressSuggestions(query);
|
||||
}, 200); // Faster debounce with Google APIs
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (updateInterval) {
|
||||
clearInterval(updateInterval);
|
||||
}
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue