ice/public/app-mapbox.js
Claude Code ec60d6bd2a Add version footer and disable map scroll wheel zoom
- Add build-time version generation script that captures git commit info
- Create /api/version endpoint to serve version data
- Add version footer component showing commit SHA, date, and branch
- Link version SHA to commit on git.deco.sh for easy navigation
- Fix footer text duplication issue with i18n translations
- Disable mouse wheel zoom on map, require +/- buttons for better UX
- Update service worker cache to v4 with new version-footer.js

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 22:37:20 -04:00

643 lines
24 KiB
JavaScript

document.addEventListener('DOMContentLoaded', async () => {
const map = L.map('map', {
scrollWheelZoom: false
}).setView([42.9634, -85.6681], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
}).addTo(map);
// Get API configuration
let config = { hasMapbox: false, mapboxAccessToken: null };
try {
const configResponse = await fetch('/api/config');
config = await configResponse.json();
console.log('🔧 API Configuration:', config.hasMapbox ? 'MapBox enabled' : 'Using Nominatim fallback');
} catch (error) {
console.warn('Failed to load API configuration, using fallback');
}
const locationForm = document.getElementById('location-form');
const messageDiv = document.getElementById('message');
const submitBtn = document.getElementById('submit-btn');
const submitText = document.getElementById('submit-text');
const submitLoading = document.getElementById('submit-loading');
const addressInput = document.getElementById('address');
const autocompleteList = document.getElementById('autocomplete-list');
// View toggle elements
const mapViewBtn = document.getElementById('map-view-btn');
const tableViewBtn = document.getElementById('table-view-btn');
const mapView = document.getElementById('map-view');
const tableView = document.getElementById('table-view');
const reportsTableBody = document.getElementById('reports-tbody');
const tableLocationCount = document.getElementById('table-location-count');
let autocompleteTimeout;
let selectedIndex = -1;
let autocompleteResults = [];
let currentMarkers = [];
let updateInterval;
let currentLocations = [];
let currentView = 'map'; // 'map' or 'table'
const clearMarkers = () => {
currentMarkers.forEach(marker => {
map.removeLayer(marker);
});
currentMarkers = [];
};
// Create custom icons for different report types
const 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]
});
};
const showMarkers = locations => {
clearMarkers();
locations.forEach(({ latitude, longitude, address, description, created_at, persistent }) => {
if (latitude && longitude) {
const timeAgo = getTimeAgo(created_at);
const isPersistent = !!persistent;
const persistentText = isPersistent ? '<br><small><strong>📌 Persistent Report</strong></small>' : '';
const marker = L.marker([latitude, longitude], {
icon: createCustomIcon(isPersistent)
})
.addTo(map)
.bindPopup(`<strong>${address}</strong><br>${description || 'No additional details'}<br><small>Reported ${timeAgo}</small>${persistentText}`);
currentMarkers.push(marker);
}
});
};
// getTimeAgo function is now available from utils.js
// Toggle between map and table view
const switchView = (viewType) => {
currentView = viewType;
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(() => map.invalidateSize(), 100);
} else {
mapView.style.display = 'none';
tableView.style.display = 'block';
mapViewBtn.classList.remove('active');
tableViewBtn.classList.add('active');
// Render table with current data
renderTable(currentLocations);
}
};
// Render table view
const renderTable = (locations) => {
if (!locations || locations.length === 0) {
reportsTableBody.innerHTML = '<tr><td colspan="4" class="loading">No active reports</td></tr>';
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 = getTimeAgo(location.created_at);
const timeRemaining = getTimeRemaining(location.created_at, location.persistent);
const remainingClass = 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' : ''}`;
};
// getTimeRemaining and getRemainingClass functions are now available from utils.js
const refreshLocations = () => {
fetch('/api/locations')
.then(res => res.json())
.then(locations => {
currentLocations = locations;
// Update map view
showMarkers(locations);
const countElement = document.getElementById('location-count');
countElement.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`;
// Update table view if it's currently visible
if (currentView === 'table') {
renderTable(locations);
}
const now = new Date();
const timeStr = now.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
countElement.title = `Last updated: ${timeStr}`;
})
.catch(err => {
console.error('Error fetching locations:', err);
document.getElementById('location-count').textContent = 'Error loading locations';
if (currentView === 'table') {
reportsTableBody.innerHTML = '<tr><td colspan="4" class="loading">Error loading reports</td></tr>';
}
});
};
refreshLocations();
updateInterval = setInterval(refreshLocations, 30000);
// MapBox Geocoding (Fast and Accurate!)
const mapboxGeocode = async (address) => {
const startTime = performance.now();
console.log(`🚀 MapBox Geocoding: "${address}"`);
try {
const encodedAddress = encodeURIComponent(address);
const response = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedAddress}.json?` +
`access_token=${config.mapboxAccessToken}&` +
`country=US&` +
`region=MI&` +
`proximity=-85.6681,42.9634&` + // Grand Rapids coordinates
`limit=1`
);
if (!response.ok) {
throw new Error(`MapBox API error: ${response.status}`);
}
const data = await response.json();
const time = (performance.now() - startTime).toFixed(2);
if (data.features && data.features.length > 0) {
const result = data.features[0];
console.log(`✅ MapBox geocoding successful in ${time}ms`);
console.log(`📍 Found: ${result.place_name}`);
return {
lat: result.center[1], // MapBox returns [lng, lat]
lon: result.center[0],
display_name: result.place_name
};
} else {
console.log(`❌ MapBox geocoding: no results (${time}ms)`);
throw new Error('No results found');
}
} catch (error) {
const time = (performance.now() - startTime).toFixed(2);
console.warn(`🚫 MapBox geocoding failed (${time}ms):`, error);
throw error;
}
};
// Nominatim Fallback (Slower but free)
const nominatimGeocode = async (address) => {
const startTime = performance.now();
console.log(`🐌 Nominatim fallback: "${address}"`);
const searches = [
address,
`${address}, Michigan`,
`${address}, MI`,
`${address}, Grand Rapids, MI`
];
for (const query of searches) {
try {
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1&countrycodes=us`);
const data = await response.json();
if (data && data.length > 0) {
const time = (performance.now() - startTime).toFixed(2);
console.log(`✅ Nominatim successful in ${time}ms`);
return data[0];
}
} catch (error) {
console.warn(`Nominatim query failed: ${query}`);
}
}
const time = (performance.now() - startTime).toFixed(2);
console.error(`💥 All geocoding failed after ${time}ms`);
throw new Error('Address not found');
};
// Main geocoding function
const geocodeAddress = async (address) => {
if (config.hasMapbox) {
try {
return await mapboxGeocode(address);
} catch (error) {
console.warn('MapBox geocoding failed, trying Nominatim fallback');
return await nominatimGeocode(address);
}
} else {
return await nominatimGeocode(address);
}
};
// MapBox Autocomplete (Lightning Fast!)
const mapboxAutocomplete = async (query) => {
const startTime = performance.now();
console.log(`⚡ MapBox Autocomplete: "${query}"`);
try {
const encodedQuery = encodeURIComponent(query);
const response = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedQuery}.json?` +
`access_token=${config.mapboxAccessToken}&` +
`country=US&` +
`region=MI&` +
`proximity=-85.6681,42.9634&` + // Grand Rapids coordinates
`types=address,poi&` +
`autocomplete=true&` +
`limit=5`
);
if (!response.ok) {
throw new Error(`MapBox API error: ${response.status}`);
}
const data = await response.json();
const time = (performance.now() - startTime).toFixed(2);
if (data.features && data.features.length > 0) {
// Filter for Michigan results
const michiganResults = data.features.filter(feature =>
feature.place_name.toLowerCase().includes('michigan') ||
feature.place_name.toLowerCase().includes(', mi,') ||
feature.context?.some(ctx => ctx.short_code === 'us-mi')
);
console.log(`⚡ MapBox autocomplete: ${michiganResults.length} results in ${time}ms`);
return michiganResults.slice(0, 5).map(feature => ({
display_name: feature.place_name,
mapbox_id: feature.id
}));
} else {
console.log(`❌ MapBox autocomplete: no results (${time}ms)`);
return [];
}
} catch (error) {
const time = (performance.now() - startTime).toFixed(2);
console.warn(`🚫 MapBox autocomplete failed (${time}ms):`, error);
return [];
}
};
// Nominatim Autocomplete Fallback
const nominatimAutocomplete = async (query) => {
console.log(`🐌 Nominatim autocomplete fallback: "${query}"`);
try {
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&countrycodes=us&addressdetails=1`);
const data = await response.json();
return data.filter(item =>
item.display_name.toLowerCase().includes('michigan') ||
item.display_name.toLowerCase().includes(', mi,')
).slice(0, 5);
} catch (error) {
console.error('Nominatim autocomplete failed:', error);
return [];
}
};
// Main autocomplete function
const fetchAddressSuggestions = async (query) => {
if (query.length < 3) {
hideAutocomplete();
return;
}
let results = [];
if (config.hasMapbox) {
try {
results = await mapboxAutocomplete(query);
} catch (error) {
console.warn('MapBox autocomplete failed, trying Nominatim');
}
}
if (results.length === 0) {
results = await nominatimAutocomplete(query);
}
autocompleteResults = results;
showAutocomplete(autocompleteResults);
};
// Form submission
locationForm.addEventListener('submit', e => {
e.preventDefault();
const address = document.getElementById('address').value;
const description = document.getElementById('description').value;
if (!address) return;
submitBtn.disabled = true;
submitText.style.display = 'none';
submitLoading.style.display = 'inline';
geocodeAddress(address)
.then(result => {
const { lat, lon } = result;
return fetch('/api/locations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address,
latitude: parseFloat(lat),
longitude: parseFloat(lon),
description,
}),
});
})
.then(res => {
if (!res.ok) {
return res.json().then(errorData => {
throw { status: res.status, data: errorData };
});
}
return res.json();
})
.then(location => {
refreshLocations();
messageDiv.textContent = 'Location reported successfully!';
messageDiv.className = 'message success';
locationForm.reset();
})
.catch(err => {
console.error('Error reporting location:', err);
// Handle specific profanity rejection
if (err.status === 400 && err.data && err.data.error === 'Submission rejected') {
messageDiv.textContent = err.data.message;
messageDiv.className = 'message error profanity-rejection';
// Clear the description field to encourage rewriting
document.getElementById('description').value = '';
document.getElementById('description').focus();
} else if (err.data && err.data.error) {
messageDiv.textContent = err.data.error;
messageDiv.className = 'message error';
} else {
messageDiv.textContent = 'Error reporting location. Please try again.';
messageDiv.className = 'message error';
}
})
.finally(() => {
submitBtn.disabled = false;
submitText.style.display = 'inline';
submitLoading.style.display = 'none';
messageDiv.style.display = 'block';
// Longer timeout for profanity rejection messages
const timeout = messageDiv.className.includes('profanity-rejection') ? 15000 : 3000;
setTimeout(() => {
messageDiv.style.display = 'none';
}, timeout);
});
});
// Autocomplete UI functions
const showAutocomplete = (results) => {
if (results.length === 0) {
hideAutocomplete();
return;
}
autocompleteList.innerHTML = '';
selectedIndex = -1;
results.forEach((result, index) => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.textContent = result.display_name;
item.addEventListener('click', () => selectAddress(result));
autocompleteList.appendChild(item);
});
autocompleteList.style.display = 'block';
};
const hideAutocomplete = () => {
autocompleteList.style.display = 'none';
selectedIndex = -1;
};
const selectAddress = (result) => {
addressInput.value = result.display_name;
hideAutocomplete();
addressInput.focus();
};
const updateSelection = (direction) => {
const items = autocompleteList.querySelectorAll('.autocomplete-item');
if (items.length === 0) return;
items[selectedIndex]?.classList.remove('selected');
if (direction === 'down') {
selectedIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0;
} else {
selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
}
items[selectedIndex].classList.add('selected');
items[selectedIndex].scrollIntoView({ block: 'nearest' });
};
// Event listeners
addressInput.addEventListener('input', (e) => {
clearTimeout(autocompleteTimeout);
const query = e.target.value.trim();
if (query.length < 3) {
hideAutocomplete();
return;
}
// Ultra-fast debounce with MapBox
autocompleteTimeout = setTimeout(() => {
fetchAddressSuggestions(query);
}, config.hasMapbox ? 150 : 300);
});
addressInput.addEventListener('keydown', (e) => {
const isListVisible = autocompleteList.style.display === 'block';
switch (e.key) {
case 'ArrowDown':
if (isListVisible) {
e.preventDefault();
updateSelection('down');
}
break;
case 'ArrowUp':
if (isListVisible) {
e.preventDefault();
updateSelection('up');
}
break;
case 'Enter':
if (isListVisible && selectedIndex >= 0) {
e.preventDefault();
selectAddress(autocompleteResults[selectedIndex]);
}
break;
case 'Escape':
hideAutocomplete();
break;
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.autocomplete-container')) {
hideAutocomplete();
}
});
// View toggle event listeners
mapViewBtn.addEventListener('click', () => switchView('map'));
tableViewBtn.addEventListener('click', () => switchView('table'));
window.addEventListener('beforeunload', () => {
if (updateInterval) {
clearInterval(updateInterval);
}
});
// Initialize theme toggle
initializeTheme();
});
// Theme toggle functionality
function initializeTheme() {
const themeToggle = document.getElementById('theme-toggle');
const themeIcon = document.querySelector('.theme-icon');
if (!themeToggle || !themeIcon) {
console.warn('Theme toggle elements not found');
return;
}
// Check for saved theme preference or default to auto (follows system)
const savedTheme = localStorage.getItem('theme') || 'auto';
applyTheme(savedTheme);
// Update icon based on current theme
updateThemeIcon(savedTheme, themeIcon);
// Add click listener for cycling through themes
themeToggle.addEventListener('click', () => {
const currentTheme = localStorage.getItem('theme') || 'auto';
let newTheme;
// Cycle: auto → light → dark → auto
switch(currentTheme) {
case 'auto':
newTheme = 'light';
break;
case 'light':
newTheme = 'dark';
break;
case 'dark':
newTheme = 'auto';
break;
default:
newTheme = 'auto';
}
localStorage.setItem('theme', newTheme);
applyTheme(newTheme);
updateThemeIcon(newTheme, themeIcon);
console.log(`Theme switched to: ${newTheme}`);
});
// Listen for system theme changes when in auto mode
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const currentTheme = localStorage.getItem('theme') || 'auto';
if (currentTheme === 'auto') {
applyTheme('auto');
}
});
}
function applyTheme(theme) {
if (theme === 'auto') {
// Detect system preference and apply appropriate theme
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-theme', theme);
}
}
function updateThemeIcon(theme, iconElement) {
switch(theme) {
case 'auto':
iconElement.textContent = '🌍'; // Globe (auto)
break;
case 'light':
iconElement.textContent = '☀️'; // Sun (light)
break;
case 'dark':
iconElement.textContent = '🌙'; // Moon (dark)
break;
}
}