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>
This commit is contained in:
parent
6c90430ff6
commit
a0fffcf4f0
13 changed files with 2170 additions and 184 deletions
399
public/map-base.js
Normal file
399
public/map-base.js
Normal file
|
@ -0,0 +1,399 @@
|
|||
/**
|
||||
* 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;
|
Loading…
Add table
Add a link
Reference in a new issue