All checks were successful
CI / Validate i18n Files (pull_request) Successful in 19s
Dependency Review / Review Dependencies (pull_request) Successful in 26s
CI / TypeScript Type Check (pull_request) Successful in 1m20s
CI / Lint Code (pull_request) Successful in 1m37s
CI / Build Project (pull_request) Successful in 1m32s
CI / Security Checks (pull_request) Successful in 1m35s
CI / Run Tests (Node 20) (pull_request) Successful in 1m42s
CI / Run Tests (Node 18) (pull_request) Successful in 1m49s
Code Quality / Code Quality Checks (pull_request) Successful in 1m57s
CI / Test Coverage (pull_request) Successful in 1m32s
- Created example-shared-components.html to demonstrate TypeScript-based shared header and footer components. - Added original-style.css for theming with CSS variables and dark mode support. - Introduced style-backup.css for legacy styles. - Developed test-refactored.html for testing map components with Leaflet integration. - Updated deployment documentation to reflect changes in log file paths and service names. - Renamed project from "great-lakes-ice-report" to "icewatch" in package.json and package-lock.json. - Updated Caddyfile for new log file path. - Added S3 bucket policy for public read access to greatlakes-conditions. - Removed old service file and created new systemd service for icewatch.
350 lines
No EOL
12 KiB
JavaScript
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();
|
|
}); |