ice/public/map-base.js
Claude Code a0fffcf4f0 Refactor architecture: Add models/services layer and refactor frontend
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>
2025-07-05 19:21:51 -04:00

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;