Major architectural improvements: - Created models/services layer for better separation of concerns - Location model with async methods for database operations - ProfanityWord model for content moderation - DatabaseService for centralized database management - ProfanityFilterService refactored to use models - Refactored frontend map implementations to share common code - MapBase class extracts 60-70% of duplicate functionality - Refactored implementations extend MapBase for specific features - Maintained unique geocoding capabilities per implementation - Updated server.js to use new service architecture - All routes now use async/await with models instead of raw queries - Enhanced error handling and maintainability 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
399 lines
No EOL
12 KiB
JavaScript
399 lines
No EOL
12 KiB
JavaScript
/**
|
|
* Base class for all map implementations
|
|
* Provides common functionality for map display, location management, and UI interactions
|
|
*/
|
|
class MapBase {
|
|
constructor() {
|
|
// Map instance (to be initialized by subclasses)
|
|
this.map = null;
|
|
this.currentMarkers = [];
|
|
|
|
// Autocomplete state
|
|
this.autocompleteTimeout = null;
|
|
this.selectedIndex = -1;
|
|
this.autocompleteResults = [];
|
|
|
|
// Update interval
|
|
this.updateInterval = null;
|
|
|
|
// DOM elements
|
|
this.addressInput = document.getElementById('address');
|
|
this.descriptionInput = document.getElementById('description');
|
|
this.submitButton = document.querySelector('.submit-btn');
|
|
this.messageElement = document.getElementById('message');
|
|
this.locationCountElement = document.getElementById('location-count');
|
|
this.lastUpdateElement = document.getElementById('last-update');
|
|
this.autocompleteContainer = document.getElementById('autocomplete');
|
|
this.locationsContainer = document.querySelector('.locations-container');
|
|
|
|
// Initialize event listeners
|
|
this.initializeEventListeners();
|
|
|
|
// Start automatic refresh
|
|
this.startAutoRefresh();
|
|
}
|
|
|
|
/**
|
|
* Initialize common event listeners
|
|
*/
|
|
initializeEventListeners() {
|
|
// Form submission
|
|
document.getElementById('location-form').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.handleSubmit();
|
|
});
|
|
|
|
// Address input with autocomplete
|
|
this.addressInput.addEventListener('input', (e) => {
|
|
this.handleAddressInput(e);
|
|
});
|
|
|
|
// Keyboard navigation for autocomplete
|
|
this.addressInput.addEventListener('keydown', (e) => {
|
|
this.handleAutocompleteKeydown(e);
|
|
});
|
|
|
|
// Click outside to close autocomplete
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.search-container')) {
|
|
this.hideAutocomplete();
|
|
}
|
|
});
|
|
|
|
// Refresh button
|
|
const refreshButton = document.getElementById('refresh-button');
|
|
if (refreshButton) {
|
|
refreshButton.addEventListener('click', () => {
|
|
this.refreshLocations();
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle address input for autocomplete
|
|
*/
|
|
handleAddressInput(e) {
|
|
clearTimeout(this.autocompleteTimeout);
|
|
const query = e.target.value.trim();
|
|
|
|
if (query.length < 3) {
|
|
this.hideAutocomplete();
|
|
return;
|
|
}
|
|
|
|
this.autocompleteTimeout = setTimeout(() => {
|
|
this.searchAddress(query);
|
|
}, 300);
|
|
}
|
|
|
|
/**
|
|
* Handle keyboard navigation in autocomplete
|
|
*/
|
|
handleAutocompleteKeydown(e) {
|
|
if (!this.autocompleteContainer.classList.contains('show')) return;
|
|
|
|
switch(e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
this.selectedIndex = Math.min(this.selectedIndex + 1, this.autocompleteResults.length - 1);
|
|
this.updateSelection();
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
|
this.updateSelection();
|
|
break;
|
|
case 'Enter':
|
|
e.preventDefault();
|
|
if (this.selectedIndex >= 0) {
|
|
this.selectAddress(this.selectedIndex);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
this.hideAutocomplete();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search for addresses - to be implemented by subclasses
|
|
*/
|
|
async searchAddress(query) {
|
|
// To be implemented by subclasses
|
|
throw new Error('searchAddress must be implemented by subclass');
|
|
}
|
|
|
|
/**
|
|
* Show autocomplete results
|
|
*/
|
|
showAutocomplete(results) {
|
|
this.autocompleteResults = results;
|
|
this.selectedIndex = -1;
|
|
|
|
if (results.length === 0) {
|
|
this.hideAutocomplete();
|
|
return;
|
|
}
|
|
|
|
this.autocompleteContainer.innerHTML = results.map((result, index) => `
|
|
<div class="autocomplete-item" data-index="${index}">
|
|
${result.display_name || result.formatted_address || result.name}
|
|
</div>
|
|
`).join('');
|
|
|
|
// Add click handlers
|
|
this.autocompleteContainer.querySelectorAll('.autocomplete-item').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
this.selectAddress(parseInt(item.dataset.index));
|
|
});
|
|
});
|
|
|
|
this.autocompleteContainer.classList.add('show');
|
|
}
|
|
|
|
/**
|
|
* Hide autocomplete dropdown
|
|
*/
|
|
hideAutocomplete() {
|
|
this.autocompleteContainer.classList.remove('show');
|
|
this.autocompleteContainer.innerHTML = '';
|
|
this.selectedIndex = -1;
|
|
}
|
|
|
|
/**
|
|
* Update visual selection in autocomplete
|
|
*/
|
|
updateSelection() {
|
|
const items = this.autocompleteContainer.querySelectorAll('.autocomplete-item');
|
|
items.forEach((item, index) => {
|
|
item.classList.toggle('selected', index === this.selectedIndex);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Select an address from autocomplete
|
|
*/
|
|
selectAddress(index) {
|
|
const result = this.autocompleteResults[index];
|
|
if (result) {
|
|
this.addressInput.value = result.display_name || result.formatted_address || result.name;
|
|
this.addressInput.dataset.lat = result.lat || result.latitude;
|
|
this.addressInput.dataset.lon = result.lon || result.longitude;
|
|
this.hideAutocomplete();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle form submission
|
|
*/
|
|
async handleSubmit() {
|
|
const address = this.addressInput.value.trim();
|
|
const description = this.descriptionInput.value.trim();
|
|
const lat = parseFloat(this.addressInput.dataset.lat);
|
|
const lon = parseFloat(this.addressInput.dataset.lon);
|
|
|
|
if (!address) {
|
|
this.showMessage('Please enter an address', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!lat || !lon || isNaN(lat) || isNaN(lon)) {
|
|
this.showMessage('Please select a valid address from the suggestions', 'error');
|
|
return;
|
|
}
|
|
|
|
// Disable submit button
|
|
this.submitButton.disabled = true;
|
|
this.submitButton.innerHTML = '<span class="loading-spinner"></span> Submitting...';
|
|
|
|
try {
|
|
const response = await fetch('/api/locations', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
address,
|
|
latitude: lat,
|
|
longitude: lon,
|
|
description
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
this.showMessage('Location reported successfully!', 'success');
|
|
this.resetForm();
|
|
this.refreshLocations();
|
|
} else {
|
|
this.handleSubmitError(data);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error submitting location:', error);
|
|
this.showMessage('Error submitting location. Please try again.', 'error');
|
|
} finally {
|
|
this.submitButton.disabled = false;
|
|
this.submitButton.innerHTML = '<i class="fas fa-flag"></i> Report Ice';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle submission errors
|
|
*/
|
|
handleSubmitError(data) {
|
|
if (data.error === 'Submission rejected' && data.message) {
|
|
// Profanity rejection
|
|
this.showMessage(data.message, 'error', 10000);
|
|
} else {
|
|
this.showMessage(data.error || 'Error submitting location', 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset form after successful submission
|
|
*/
|
|
resetForm() {
|
|
this.addressInput.value = '';
|
|
this.addressInput.dataset.lat = '';
|
|
this.addressInput.dataset.lon = '';
|
|
this.descriptionInput.value = '';
|
|
}
|
|
|
|
/**
|
|
* Show a message to the user
|
|
*/
|
|
showMessage(text, type = 'info', duration = 5000) {
|
|
this.messageElement.textContent = text;
|
|
this.messageElement.className = `message ${type} show`;
|
|
|
|
setTimeout(() => {
|
|
this.messageElement.classList.remove('show');
|
|
}, duration);
|
|
}
|
|
|
|
/**
|
|
* Clear all markers from the map
|
|
*/
|
|
clearMarkers() {
|
|
this.currentMarkers.forEach(marker => {
|
|
if (this.map && marker) {
|
|
this.map.removeLayer(marker);
|
|
}
|
|
});
|
|
this.currentMarkers = [];
|
|
}
|
|
|
|
/**
|
|
* Create a marker - to be implemented by subclasses
|
|
*/
|
|
createMarker(location) {
|
|
// To be implemented by subclasses
|
|
throw new Error('createMarker must be implemented by subclass');
|
|
}
|
|
|
|
/**
|
|
* Refresh locations from the server
|
|
*/
|
|
async refreshLocations() {
|
|
try {
|
|
const response = await fetch('/api/locations');
|
|
const locations = await response.json();
|
|
|
|
this.displayLocations(locations);
|
|
this.updateLocationCount(locations.length);
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching locations:', error);
|
|
this.showMessage('Error loading locations', 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Display locations on the map
|
|
*/
|
|
displayLocations(locations) {
|
|
this.clearMarkers();
|
|
|
|
locations.forEach(location => {
|
|
if (location.latitude && location.longitude) {
|
|
const marker = this.createMarker(location);
|
|
if (marker) {
|
|
this.currentMarkers.push(marker);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Update locations list if container exists
|
|
if (this.locationsContainer) {
|
|
this.updateLocationsList(locations);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the locations list (for table view)
|
|
*/
|
|
updateLocationsList(locations) {
|
|
if (!this.locationsContainer) return;
|
|
|
|
if (locations.length === 0) {
|
|
this.locationsContainer.innerHTML = '<p class="no-reports">No ice reports in the last 48 hours</p>';
|
|
return;
|
|
}
|
|
|
|
this.locationsContainer.innerHTML = locations.map(location => `
|
|
<div class="location-card">
|
|
<h3>${location.address}</h3>
|
|
${location.description ? `<p class="description">${location.description}</p>` : ''}
|
|
<p class="time-ago">${window.getTimeAgo ? window.getTimeAgo(location.created_at) : ''}</p>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
/**
|
|
* Update location count display
|
|
*/
|
|
updateLocationCount(count) {
|
|
if (this.locationCountElement) {
|
|
const plural = count !== 1 ? 's' : '';
|
|
this.locationCountElement.textContent = `${count} report${plural}`;
|
|
}
|
|
|
|
if (this.lastUpdateElement) {
|
|
this.lastUpdateElement.textContent = `Last updated: ${new Date().toLocaleTimeString()}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start automatic refresh of locations
|
|
*/
|
|
startAutoRefresh() {
|
|
// Initial load
|
|
this.refreshLocations();
|
|
|
|
// Refresh every 30 seconds
|
|
this.updateInterval = setInterval(() => {
|
|
this.refreshLocations();
|
|
}, 30000);
|
|
}
|
|
|
|
/**
|
|
* Stop automatic refresh
|
|
*/
|
|
stopAutoRefresh() {
|
|
if (this.updateInterval) {
|
|
clearInterval(this.updateInterval);
|
|
this.updateInterval = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the map - to be implemented by subclasses
|
|
*/
|
|
initializeMap() {
|
|
throw new Error('initializeMap must be implemented by subclass');
|
|
}
|
|
}
|
|
|
|
// Export for use in other files
|
|
window.MapBase = MapBase; |