ice/public/app-mapbox-refactored.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

350 lines
No EOL
12 KiB
JavaScript

/**
* 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();
});