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:
Claude Code 2025-07-05 19:21:51 -04:00
parent 6c90430ff6
commit a0fffcf4f0
13 changed files with 2170 additions and 184 deletions

View file

@ -0,0 +1,350 @@
/**
* MapBox implementation with table view and theme toggle using MapBase
*/
class MapBoxApp extends MapBase {
constructor() {
super();
this.config = { hasMapbox: false, mapboxAccessToken: null };
this.currentView = 'map'; // 'map' or 'table'
this.initializeConfig();
this.initializeMapBoxFeatures();
}
/**
* Initialize API configuration
*/
async initializeConfig() {
try {
const configResponse = await fetch('/api/config');
this.config = await configResponse.json();
console.log('🔧 API Configuration:', this.config.hasMapbox ? 'MapBox enabled' : 'Using Nominatim fallback');
} catch (error) {
console.warn('Failed to load API configuration, using fallback');
}
this.initializeMap();
}
/**
* Initialize MapBox-specific features
*/
initializeMapBoxFeatures() {
this.initializeViewToggle();
this.initializeThemeToggle();
}
/**
* Initialize the Leaflet map
*/
initializeMap() {
this.map = L.map('map').setView([42.9634, -85.6681], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
}).addTo(this.map);
}
/**
* Initialize view toggle functionality
*/
initializeViewToggle() {
const mapViewBtn = document.getElementById('map-view-btn');
const tableViewBtn = document.getElementById('table-view-btn');
if (mapViewBtn && tableViewBtn) {
mapViewBtn.addEventListener('click', () => this.switchView('map'));
tableViewBtn.addEventListener('click', () => this.switchView('table'));
}
}
/**
* Initialize theme toggle functionality
*/
initializeThemeToggle() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => this.toggleTheme());
}
// Apply saved theme on load
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.body.dataset.theme = savedTheme;
}
}
/**
* Toggle between light and dark theme
*/
toggleTheme() {
const currentTheme = document.body.dataset.theme || 'light';
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
document.body.dataset.theme = newTheme;
localStorage.setItem('theme', newTheme);
}
/**
* Switch between map and table view
*/
switchView(viewType) {
this.currentView = viewType;
const mapView = document.getElementById('map-view');
const tableView = document.getElementById('table-view');
const mapViewBtn = document.getElementById('map-view-btn');
const tableViewBtn = document.getElementById('table-view-btn');
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(() => this.map.invalidateSize(), 100);
} else {
mapView.style.display = 'none';
tableView.style.display = 'block';
mapViewBtn.classList.remove('active');
tableViewBtn.classList.add('active');
}
}
/**
* Create custom marker for location
*/
createMarker(location) {
const isPersistent = !!location.persistent;
const timeAgo = window.getTimeAgo ? window.getTimeAgo(location.created_at) : '';
const persistentText = isPersistent ? '<br><small><strong>📌 Persistent Report</strong></small>' : '';
const customIcon = this.createCustomIcon(isPersistent);
const marker = L.marker([location.latitude, location.longitude], {
icon: customIcon
})
.addTo(this.map)
.bindPopup(`<strong>${location.address}</strong><br>${location.description || 'No additional details'}<br><small>Reported ${timeAgo}</small>${persistentText}`);
return marker;
}
/**
* Create custom icon for markers
*/
createCustomIcon(isPersistent = false) {
const iconColor = isPersistent ? '#28a745' : '#dc3545';
const iconSymbol = isPersistent ? '🔒' : '⚠️';
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]
});
}
/**
* Display locations (override to handle table view)
*/
displayLocations(locations) {
super.displayLocations(locations);
// Update table view if currently active
if (this.currentView === 'table') {
this.renderTable(locations);
}
}
/**
* Render table view
*/
renderTable(locations) {
const reportsTableBody = document.getElementById('reports-tbody');
const tableLocationCount = document.getElementById('table-location-count');
if (!reportsTableBody || !locations || locations.length === 0) {
if (reportsTableBody) {
reportsTableBody.innerHTML = '<tr><td colspan="4" class="loading">No active reports</td></tr>';
}
if (tableLocationCount) {
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 = window.getTimeAgo ? window.getTimeAgo(location.created_at) : '';
const timeRemaining = this.getTimeRemaining(location.created_at, location.persistent);
const remainingClass = this.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' : ''}`;
}
/**
* Get time remaining for a report
*/
getTimeRemaining(createdAt, isPersistent) {
if (isPersistent) {
return '♾️ Persistent';
}
const created = new Date(createdAt);
const now = new Date();
const elapsed = now - created;
const remaining = (48 * 60 * 60 * 1000) - elapsed; // 48 hours in ms
if (remaining <= 0) {
return '⏰ Expired';
}
const hours = Math.floor(remaining / (60 * 60 * 1000));
const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000));
if (hours >= 24) {
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
return `${days}d ${remainingHours}h`;
}
return `${hours}h ${minutes}m`;
}
/**
* Get CSS class for time remaining
*/
getRemainingClass(createdAt, isPersistent) {
if (isPersistent) {
return 'persistent';
}
const created = new Date(createdAt);
const now = new Date();
const elapsed = now - created;
const remaining = (48 * 60 * 60 * 1000) - elapsed;
if (remaining <= 0) {
return 'expired';
} else if (remaining < 6 * 60 * 60 * 1000) { // Less than 6 hours
return 'expiring-soon';
} else if (remaining < 24 * 60 * 60 * 1000) { // Less than 24 hours
return 'expiring-today';
}
return 'fresh';
}
/**
* Search for addresses using MapBox or Nominatim fallback
*/
async searchAddress(query) {
try {
let results = [];
// Try MapBox first if available
if (this.config.hasMapbox && this.config.mapboxAccessToken) {
try {
results = await this.searchMapBox(query);
if (results.length > 0) {
this.showAutocomplete(results);
return;
}
} catch (error) {
console.warn('MapBox search failed, falling back to Nominatim:', error);
}
}
// Fallback to Nominatim
results = await this.searchNominatim(query);
this.showAutocomplete(results);
} catch (error) {
console.error('Error searching addresses:', error);
this.showAutocomplete([]);
}
}
/**
* Search using MapBox API
*/
async searchMapBox(query) {
const response = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${this.config.mapboxAccessToken}&country=us&proximity=-85.6681,42.9634&limit=5`);
const data = await response.json();
if (data.features && data.features.length > 0) {
return data.features.map(feature => ({
display_name: feature.place_name,
lat: feature.center[1],
lon: feature.center[0]
}));
}
return [];
}
/**
* Search using Nominatim as fallback
*/
async searchNominatim(query) {
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&extratags=1&limit=5&q=${encodeURIComponent(query + ', Michigan')}`);
const data = await response.json();
if (data && data.length > 0) {
return data.slice(0, 5);
}
return [];
}
/**
* Handle submission errors (override to handle profanity rejection)
*/
handleSubmitError(data) {
if (data.error === 'Submission rejected' && data.message) {
// Enhanced profanity rejection handling
this.showMessage(data.message, 'error', 10000);
} else {
super.handleSubmitError(data);
}
}
}
// Initialize the app when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new MapBoxApp();
});

221
public/app-refactored.js Normal file
View file

@ -0,0 +1,221 @@
/**
* Nominatim-only implementation using MapBase
*/
class NominatimMapApp extends MapBase {
constructor() {
super();
this.initializeMap();
}
/**
* Initialize the Leaflet map
*/
initializeMap() {
this.map = L.map('map').setView([42.9634, -85.6681], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
}).addTo(this.map);
}
/**
* Create custom marker for location
*/
createMarker(location) {
const isPersistent = !!location.persistent;
const timeAgo = window.getTimeAgo ? window.getTimeAgo(location.created_at) : '';
const persistentText = isPersistent ? '<br><small><strong>📌 Persistent Report</strong></small>' : '';
const customIcon = this.createCustomIcon(isPersistent);
const marker = L.marker([location.latitude, location.longitude], {
icon: customIcon
})
.addTo(this.map)
.bindPopup(`<strong>${location.address}</strong><br>${location.description || 'No additional details'}<br><small>Reported ${timeAgo}</small>${persistentText}`);
return marker;
}
/**
* Create custom icon for markers
*/
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]
});
}
/**
* Search for addresses using Nominatim
*/
async searchAddress(query) {
try {
// Generate multiple search variations for better results
const searches = [
query, // Original query
query.replace(' & ', ' and '), // Replace & with 'and'
query.replace(' and ', ' & '), // Replace 'and' with &
query.replace(' at ', ' & '), // Replace 'at' with &
`${query}, Michigan`, // Add Michigan if not present
`${query}, MI`, // Add MI if not present
];
// Add street type variations
const streetVariations = [];
searches.forEach(search => {
streetVariations.push(search);
streetVariations.push(search.replace(' St ', ' Street '));
streetVariations.push(search.replace(' Street ', ' St '));
streetVariations.push(search.replace(' Ave ', ' Avenue '));
streetVariations.push(search.replace(' Avenue ', ' Ave '));
streetVariations.push(search.replace(' Rd ', ' Road '));
streetVariations.push(search.replace(' Road ', ' Rd '));
streetVariations.push(search.replace(' Blvd ', ' Boulevard '));
streetVariations.push(search.replace(' Boulevard ', ' Blvd '));
});
// Remove duplicates
const uniqueSearches = [...new Set(streetVariations)];
// Try each search variation until we get results
for (const searchQuery of uniqueSearches) {
try {
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&extratags=1&limit=5&q=${encodeURIComponent(searchQuery)}`);
const data = await response.json();
if (data && data.length > 0) {
// Prefer results that are in Michigan/Grand Rapids
const michiganResults = data.filter(item =>
item.display_name.toLowerCase().includes('michigan') ||
item.display_name.toLowerCase().includes('grand rapids') ||
item.display_name.toLowerCase().includes(', mi')
);
const results = michiganResults.length > 0 ? michiganResults : data;
this.showAutocomplete(results.slice(0, 5));
return;
}
} catch (error) {
console.warn(`Geocoding failed for: "${searchQuery}"`, error);
}
}
// If no results found, show empty autocomplete
this.showAutocomplete([]);
} catch (error) {
console.error('Error searching addresses:', error);
this.showAutocomplete([]);
}
}
/**
* Handle form submission with enhanced geocoding
*/
async handleSubmit() {
const address = this.addressInput.value.trim();
const description = this.descriptionInput.value.trim();
if (!address) {
this.showMessage('Please enter an address', 'error');
return;
}
// If coordinates are not set, try to geocode the address
let lat = parseFloat(this.addressInput.dataset.lat);
let lon = parseFloat(this.addressInput.dataset.lon);
if (!lat || !lon || isNaN(lat) || isNaN(lon)) {
// Try to geocode the current address value
const geocodeResult = await this.geocodeAddress(address);
if (geocodeResult) {
lat = parseFloat(geocodeResult.lat);
lon = parseFloat(geocodeResult.lon);
} else {
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';
}
}
/**
* Geocode an address using Nominatim
*/
async geocodeAddress(address) {
try {
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(address + ', Michigan')}`);
const data = await response.json();
if (data && data.length > 0) {
return data[0];
}
return null;
} catch (error) {
console.error('Error geocoding address:', error);
return null;
}
}
}
// Initialize the app when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new NominatimMapApp();
});

399
public/map-base.js Normal file
View 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;

266
public/test-refactored.html Normal file
View file

@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Refactored Maps</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="style.css">
<style>
.test-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.test-section {
margin-bottom: 40px;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
}
.test-section h2 {
margin-top: 0;
color: #333;
}
#map {
height: 400px;
width: 100%;
border-radius: 8px;
}
.map-controls {
margin-bottom: 20px;
}
.btn {
padding: 10px 20px;
margin: 5px;
border: none;
border-radius: 4px;
cursor: pointer;
background: #007bff;
color: white;
}
.btn.active {
background: #0056b3;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input, .form-group textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.search-container {
position: relative;
}
#autocomplete {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 4px 4px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
display: none;
}
#autocomplete.show {
display: block;
}
.autocomplete-item {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #eee;
}
.autocomplete-item:hover,
.autocomplete-item.selected {
background: #f0f0f0;
}
.message {
padding: 10px;
margin: 10px 0;
border-radius: 4px;
display: none;
}
.message.show {
display: block;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.location-stats {
margin: 10px 0;
padding: 10px;
background: #f8f9fa;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="test-container">
<h1>Refactored Map Components Test</h1>
<div class="test-section">
<h2>Map Display</h2>
<div class="map-controls">
<button id="map-view-btn" class="btn active">Map View</button>
<button id="table-view-btn" class="btn">Table View</button>
<button id="theme-toggle" class="btn">Toggle Theme</button>
<button id="refresh-button" class="btn">Refresh</button>
</div>
<div id="map-view">
<div id="map"></div>
</div>
<div id="table-view" style="display: none;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa;">
<th style="padding: 10px; border: 1px solid #ddd;">Location</th>
<th style="padding: 10px; border: 1px solid #ddd;">Details</th>
<th style="padding: 10px; border: 1px solid #ddd;">Reported</th>
<th style="padding: 10px; border: 1px solid #ddd;">Expires</th>
</tr>
</thead>
<tbody id="reports-tbody">
<tr><td colspan="4" style="padding: 20px; text-align: center;">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="location-stats">
<span id="location-count">Loading...</span> |
<span id="last-update">Initializing...</span> |
<span id="table-location-count">0 reports</span>
</div>
</div>
<div class="test-section">
<h2>Location Submission Form</h2>
<form id="location-form">
<div class="form-group">
<label for="address">Address:</label>
<div class="search-container">
<input type="text" id="address" placeholder="Enter address or intersection..." required>
<div id="autocomplete"></div>
</div>
</div>
<div class="form-group">
<label for="description">Description (optional):</label>
<textarea id="description" placeholder="Describe road conditions..." rows="3"></textarea>
</div>
<button type="submit" class="submit-btn btn">
<i class="fas fa-flag"></i> Report Ice
</button>
</form>
<div id="message" class="message"></div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="utils.js"></script>
<script src="map-base.js"></script>
<script>
// Test both implementations
document.addEventListener('DOMContentLoaded', () => {
// Create a simple test implementation
class TestMapApp extends MapBase {
constructor() {
super();
this.initializeMap();
this.initializeTestFeatures();
}
initializeMap() {
this.map = L.map('map').setView([42.9634, -85.6681], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
}).addTo(this.map);
}
initializeTestFeatures() {
// View toggle
const mapViewBtn = document.getElementById('map-view-btn');
const tableViewBtn = document.getElementById('table-view-btn');
const themeToggle = document.getElementById('theme-toggle');
if (mapViewBtn) mapViewBtn.addEventListener('click', () => this.switchView('map'));
if (tableViewBtn) tableViewBtn.addEventListener('click', () => this.switchView('table'));
if (themeToggle) themeToggle.addEventListener('click', () => this.toggleTheme());
}
switchView(viewType) {
const mapView = document.getElementById('map-view');
const tableView = document.getElementById('table-view');
const mapViewBtn = document.getElementById('map-view-btn');
const tableViewBtn = document.getElementById('table-view-btn');
if (viewType === 'map') {
mapView.style.display = 'block';
tableView.style.display = 'none';
mapViewBtn.classList.add('active');
tableViewBtn.classList.remove('active');
setTimeout(() => this.map.invalidateSize(), 100);
} else {
mapView.style.display = 'none';
tableView.style.display = 'block';
mapViewBtn.classList.remove('active');
tableViewBtn.classList.add('active');
}
}
toggleTheme() {
const currentTheme = document.body.dataset.theme || 'light';
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
document.body.dataset.theme = newTheme;
}
createMarker(location) {
const isPersistent = !!location.persistent;
const timeAgo = window.getTimeAgo ? window.getTimeAgo(location.created_at) : '';
const marker = L.marker([location.latitude, location.longitude])
.addTo(this.map)
.bindPopup(`<strong>${location.address}</strong><br>${location.description || 'No details'}<br><small>${timeAgo}</small>`);
return marker;
}
async searchAddress(query) {
try {
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=5&q=${encodeURIComponent(query + ', Michigan')}`);
const data = await response.json();
this.showAutocomplete(data.slice(0, 5));
} catch (error) {
console.error('Search failed:', error);
this.showAutocomplete([]);
}
}
}
new TestMapApp();
});
</script>
</body>
</html>