feat: Add shared components and styling for icewatch application
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
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.
This commit is contained in:
parent
d9944a6a4c
commit
96e2619aa2
16 changed files with 19 additions and 30 deletions
421
archive/app-google.js
Normal file
421
archive/app-google.js
Normal file
|
@ -0,0 +1,421 @@
|
|||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const map = L.map('map').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 = { hasGoogleMaps: false, googleMapsApiKey: null };
|
||||
try {
|
||||
const configResponse = await fetch('/api/config');
|
||||
config = await configResponse.json();
|
||||
console.log('🔧 API Configuration:', config.hasGoogleMaps ? 'Google Maps 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');
|
||||
|
||||
let autocompleteTimeout;
|
||||
let selectedIndex = -1;
|
||||
let autocompleteResults = [];
|
||||
let currentMarkers = [];
|
||||
let updateInterval;
|
||||
let googleAutocompleteService = null;
|
||||
let googleGeocoderService = null;
|
||||
|
||||
// Initialize Google Services if API key is available
|
||||
if (config.hasGoogleMaps) {
|
||||
try {
|
||||
// Load Google Maps API
|
||||
await loadGoogleMapsAPI(config.googleMapsApiKey);
|
||||
googleAutocompleteService = new google.maps.places.AutocompleteService();
|
||||
googleGeocoderService = new google.maps.Geocoder();
|
||||
console.log('✅ Google Maps services initialized');
|
||||
} catch (error) {
|
||||
console.warn('❌ Failed to initialize Google Maps, falling back to Nominatim');
|
||||
}
|
||||
}
|
||||
|
||||
function loadGoogleMapsAPI(apiKey) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.google) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
const clearMarkers = () => {
|
||||
currentMarkers.forEach(marker => {
|
||||
map.removeLayer(marker);
|
||||
});
|
||||
currentMarkers = [];
|
||||
};
|
||||
|
||||
const showMarkers = locations => {
|
||||
clearMarkers();
|
||||
|
||||
locations.forEach(({ latitude, longitude, address, description, created_at }) => {
|
||||
if (latitude && longitude) {
|
||||
const timeAgo = getTimeAgo(created_at);
|
||||
const marker = L.marker([latitude, longitude])
|
||||
.addTo(map)
|
||||
.bindPopup(`<strong>${address}</strong><br>${description || 'No additional details'}<br><small>Reported ${timeAgo}</small>`);
|
||||
currentMarkers.push(marker);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// getTimeAgo function is now available from utils.js
|
||||
|
||||
const refreshLocations = () => {
|
||||
fetch('/api/locations')
|
||||
.then(res => res.json())
|
||||
.then(locations => {
|
||||
showMarkers(locations);
|
||||
const countElement = document.getElementById('location-count');
|
||||
countElement.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`;
|
||||
|
||||
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';
|
||||
});
|
||||
};
|
||||
|
||||
refreshLocations();
|
||||
updateInterval = setInterval(refreshLocations, 30000);
|
||||
|
||||
// Google Maps Geocoding (Fast!)
|
||||
const googleGeocode = async (address) => {
|
||||
const startTime = performance.now();
|
||||
console.log(`🚀 Google Geocoding: "${address}"`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
googleGeocoderService.geocode({
|
||||
address: address,
|
||||
componentRestrictions: { country: 'US', administrativeArea: 'MI' },
|
||||
region: 'us'
|
||||
}, (results, status) => {
|
||||
const time = (performance.now() - startTime).toFixed(2);
|
||||
|
||||
if (status === 'OK' && results.length > 0) {
|
||||
const result = results[0];
|
||||
console.log(`✅ Google geocoding successful in ${time}ms`);
|
||||
console.log(`📍 Found: ${result.formatted_address}`);
|
||||
|
||||
resolve({
|
||||
lat: result.geometry.location.lat(),
|
||||
lon: result.geometry.location.lng(),
|
||||
display_name: result.formatted_address
|
||||
});
|
||||
} else {
|
||||
console.log(`❌ Google geocoding failed: ${status} (${time}ms)`);
|
||||
reject(new Error(`Google geocoding failed: ${status}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 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 (googleGeocoderService) {
|
||||
try {
|
||||
return await googleGeocode(address);
|
||||
} catch (error) {
|
||||
console.warn('Google geocoding failed, trying Nominatim fallback');
|
||||
return await nominatimGeocode(address);
|
||||
}
|
||||
} else {
|
||||
return await nominatimGeocode(address);
|
||||
}
|
||||
};
|
||||
|
||||
// Google Places Autocomplete (Super fast!)
|
||||
const googleAutocomplete = async (query) => {
|
||||
const startTime = performance.now();
|
||||
console.log(`⚡ Google Autocomplete: "${query}"`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
googleAutocompleteService.getPlacePredictions({
|
||||
input: query,
|
||||
componentRestrictions: { country: 'us' },
|
||||
types: ['address'],
|
||||
location: new google.maps.LatLng(42.9634, -85.6681),
|
||||
radius: 50000 // 50km around Grand Rapids
|
||||
}, (predictions, status) => {
|
||||
const time = (performance.now() - startTime).toFixed(2);
|
||||
|
||||
if (status === google.maps.places.PlacesServiceStatus.OK && predictions) {
|
||||
const michiganResults = predictions.filter(p =>
|
||||
p.description.toLowerCase().includes('mi,') ||
|
||||
p.description.toLowerCase().includes('michigan')
|
||||
);
|
||||
|
||||
console.log(`⚡ Google autocomplete: ${michiganResults.length} results in ${time}ms`);
|
||||
resolve(michiganResults.slice(0, 5).map(p => ({
|
||||
display_name: p.description,
|
||||
place_id: p.place_id
|
||||
})));
|
||||
} else {
|
||||
console.log(`❌ Google autocomplete failed: ${status} (${time}ms)`);
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 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 (googleAutocompleteService) {
|
||||
try {
|
||||
results = await googleAutocomplete(query);
|
||||
} catch (error) {
|
||||
console.warn('Google 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 => res.json())
|
||||
.then(location => {
|
||||
refreshLocations();
|
||||
messageDiv.textContent = 'Location reported successfully!';
|
||||
messageDiv.className = 'message success';
|
||||
locationForm.reset();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error reporting location:', err);
|
||||
messageDiv.textContent = 'Error reporting location.';
|
||||
messageDiv.className = 'message error';
|
||||
})
|
||||
.finally(() => {
|
||||
submitBtn.disabled = false;
|
||||
submitText.style.display = 'inline';
|
||||
submitLoading.style.display = 'none';
|
||||
messageDiv.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = 'none';
|
||||
}, 3000);
|
||||
});
|
||||
});
|
||||
|
||||
// Autocomplete UI functions (same as before)
|
||||
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;
|
||||
}
|
||||
|
||||
autocompleteTimeout = setTimeout(() => {
|
||||
fetchAddressSuggestions(query);
|
||||
}, 200); // Faster debounce with Google APIs
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (updateInterval) {
|
||||
clearInterval(updateInterval);
|
||||
}
|
||||
});
|
||||
});
|
350
archive/app-mapbox-refactored.js
Normal file
350
archive/app-mapbox-refactored.js
Normal 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();
|
||||
});
|
643
archive/app-mapbox.js
Normal file
643
archive/app-mapbox.js
Normal file
|
@ -0,0 +1,643 @@
|
|||
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;
|
||||
}
|
||||
}
|
221
archive/app-refactored.js
Normal file
221
archive/app-refactored.js
Normal 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();
|
||||
});
|
47
archive/example-shared-components.html
Normal file
47
archive/example-shared-components.html
Normal file
|
@ -0,0 +1,47 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Shared Components Example</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
|
||||
<!-- Internationalization -->
|
||||
<script src="i18n.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Container for shared header -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<div class="content">
|
||||
<div class="form-section">
|
||||
<h2>Example Page Using Shared Components</h2>
|
||||
<p>This page demonstrates the new TypeScript-based shared header and footer components.</p>
|
||||
|
||||
<h3>Benefits:</h3>
|
||||
<ul>
|
||||
<li>✅ Type-safe TypeScript components</li>
|
||||
<li>✅ Consistent headers/footers across all pages</li>
|
||||
<li>✅ Easy to maintain - change once, update everywhere</li>
|
||||
<li>✅ Built-in i18n support</li>
|
||||
<li>✅ Automatic theme toggle functionality</li>
|
||||
</ul>
|
||||
|
||||
<h3>How to use:</h3>
|
||||
<pre><code>// Include the compiled bundle
|
||||
<script src="dist/app-main.js"></script>
|
||||
|
||||
// The shared components are automatically rendered
|
||||
// into #header-container and #footer-container</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container for shared footer -->
|
||||
<div id="footer-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Include the compiled TypeScript bundle -->
|
||||
<script src="dist/app-main.js"></script>
|
||||
</body>
|
||||
</html>
|
571
archive/original-style.css
Normal file
571
archive/original-style.css
Normal file
|
@ -0,0 +1,571 @@
|
|||
/* CSS Variables for theming */
|
||||
:root {
|
||||
--bg-color: #f4f4f9;
|
||||
--text-color: #333;
|
||||
--card-bg: white;
|
||||
--border-color: #ddd;
|
||||
--input-bg: white;
|
||||
--input-border: #ddd;
|
||||
--button-bg: #007bff;
|
||||
--button-hover: #0056b3;
|
||||
--header-bg: transparent;
|
||||
--footer-border: #ddd;
|
||||
--table-header-bg: #f8f9fa;
|
||||
--table-hover: #f8f9fa;
|
||||
--toggle-bg: #f8f9fa;
|
||||
--toggle-border: #dee2e6;
|
||||
--toggle-active-bg: #007bff;
|
||||
--shadow: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Dark mode variables */
|
||||
[data-theme="dark"] {
|
||||
--bg-color: #1a1a1a;
|
||||
--text-color: #e0e0e0;
|
||||
--card-bg: #2d2d2d;
|
||||
--border-color: #404040;
|
||||
--input-bg: #333;
|
||||
--input-border: #555;
|
||||
--button-bg: #4a90e2;
|
||||
--button-hover: #357abd;
|
||||
--header-bg: transparent;
|
||||
--footer-border: #404040;
|
||||
--table-header-bg: #3a3a3a;
|
||||
--table-hover: #3a3a3a;
|
||||
--toggle-bg: #404040;
|
||||
--toggle-border: #555;
|
||||
--toggle-active-bg: #4a90e2;
|
||||
--shadow: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Auto system theme detection */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-theme="auto"] {
|
||||
--bg-color: #1a1a1a;
|
||||
--text-color: #e0e0e0;
|
||||
--card-bg: #2d2d2d;
|
||||
--border-color: #404040;
|
||||
--input-bg: #333;
|
||||
--input-border: #555;
|
||||
--button-bg: #4a90e2;
|
||||
--button-hover: #357abd;
|
||||
--header-bg: transparent;
|
||||
--footer-border: #404040;
|
||||
--table-header-bg: #3a3a3a;
|
||||
--table-hover: #3a3a3a;
|
||||
--toggle-bg: #404040;
|
||||
--toggle-border: #555;
|
||||
--toggle-active-bg: #4a90e2;
|
||||
--shadow: rgba(0,0,0,0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-theme="auto"] {
|
||||
--bg-color: #f4f4f9;
|
||||
--text-color: #333;
|
||||
--card-bg: white;
|
||||
--border-color: #ddd;
|
||||
--input-bg: white;
|
||||
--input-border: #ddd;
|
||||
--button-bg: #007bff;
|
||||
--button-hover: #0056b3;
|
||||
--header-bg: transparent;
|
||||
--footer-border: #ddd;
|
||||
--table-header-bg: #f8f9fa;
|
||||
--table-hover: #f8f9fa;
|
||||
--toggle-bg: #f8f9fa;
|
||||
--toggle-border: #dee2e6;
|
||||
--toggle-active-bg: #007bff;
|
||||
--shadow: rgba(0,0,0,0.1);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.map-section, .form-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px var(--shadow);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.map-section {
|
||||
height: auto;
|
||||
min-height: 650px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
input[type="text"], textarea {
|
||||
width: calc(100% - 20px);
|
||||
padding: 8px;
|
||||
margin-top: 5px;
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 4px;
|
||||
background-color: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.autocomplete-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.autocomplete-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
box-shadow: 0 4px 6px var(--shadow);
|
||||
}
|
||||
|
||||
.autocomplete-item {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.autocomplete-item:hover,
|
||||
.autocomplete-item.selected {
|
||||
background-color: var(--table-hover);
|
||||
}
|
||||
|
||||
.autocomplete-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.input-help {
|
||||
color: #666;
|
||||
font-size: 0.85em;
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
background-color: var(--button-bg);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease-in;
|
||||
}
|
||||
|
||||
button[type="submit"]:hover {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
display: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
/* Header layout for theme toggle */
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Theme toggle button */
|
||||
.theme-toggle {
|
||||
background: var(--card-bg);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px var(--shadow);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 8px var(--shadow);
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
font-size: 20px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .theme-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
margin-top: 30px;
|
||||
border-top: 1px solid var(--footer-border);
|
||||
clear: both;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
font-size: 0.8em;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
/* Reports header and toggle */
|
||||
.reports-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background-color: var(--toggle-bg);
|
||||
border: 2px solid var(--toggle-border);
|
||||
color: var(--text-color);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background-color: var(--toggle-active-bg);
|
||||
border-color: var(--toggle-active-bg);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toggle-btn.active:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* View containers */
|
||||
.view-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Table view styles */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.reports-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--card-bg);
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.reports-table th {
|
||||
background-color: var(--table-header-bg);
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.reports-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.reports-table tr:hover {
|
||||
background-color: var(--table-hover);
|
||||
}
|
||||
|
||||
.reports-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.table-info {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Table cell specific styles */
|
||||
.location-cell {
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.details-cell {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
font-size: 12px;
|
||||
color: #868e96;
|
||||
}
|
||||
|
||||
.remaining-cell {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.remaining-cell.urgent {
|
||||
color: #dc3545;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.remaining-cell.warning {
|
||||
color: #fd7e14;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.remaining-cell.normal {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
header p {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.map-section, .form-section {
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#map {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
input[type="text"], textarea {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
width: calc(100% - 16px);
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.reports-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reports-header h2 {
|
||||
font-size: 1.3em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.reports-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.reports-table th,
|
||||
.reports-table td {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.location-cell {
|
||||
max-width: 120px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.details-cell {
|
||||
max-width: 100px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.remaining-cell {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.autocomplete-list {
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.input-help {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 20px 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small screens */
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.map-section, .form-section {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#map {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.reports-table th {
|
||||
font-size: 10px;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.reports-table td {
|
||||
font-size: 10px;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.location-cell {
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.details-cell {
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
font-size: 12px;
|
||||
padding: 10px 6px;
|
||||
}
|
||||
}
|
370
archive/style-backup.css
Normal file
370
archive/style-backup.css
Normal file
|
@ -0,0 +1,370 @@
|
|||
:root {
|
||||
--primary-color: #2563eb;
|
||||
--secondary-color: #64748b;
|
||||
--accent-color: #3b82f6;
|
||||
--danger-color: #ef4444;
|
||||
--warning-color: #f59e0b;
|
||||
--success-color: #10b981;
|
||||
--text-color: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--text-tertiary: #9ca3af;
|
||||
--text-muted: #d1d5db;
|
||||
--bg-color: #ffffff;
|
||||
--bg-secondary: #f9fafb;
|
||||
--bg-tertiary: #f3f4f6;
|
||||
--bg-hover: #f1f5f9;
|
||||
--border-color: #e5e7eb;
|
||||
--border-secondary: #d1d5db;
|
||||
--status-active: #10b981;
|
||||
--status-warning: #f59e0b;
|
||||
--status-danger: #ef4444;
|
||||
--status-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme=dark] {
|
||||
--text-color: #f9fafb;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-tertiary: #9ca3af;
|
||||
--text-muted: #6b7280;
|
||||
--bg-color: #111827;
|
||||
--bg-secondary: #1f2937;
|
||||
--bg-tertiary: #374151;
|
||||
--bg-hover: #4b5563;
|
||||
--border-color: #374151;
|
||||
--border-secondary: #4b5563;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1.5rem;
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table th, .table td {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
.table th {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
.table th:first-child {
|
||||
border-top-left-radius: 0.75rem;
|
||||
}
|
||||
.table th:last-child {
|
||||
border-top-right-radius: 0.75rem;
|
||||
}
|
||||
.table td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
vertical-align: top;
|
||||
}
|
||||
.table td:first-child {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.table td:last-child {
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
.table tbody tr {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.table tbody tr:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.reports-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1.5rem;
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.reports-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.reports-table th, .reports-table td {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
.reports-table th {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
.reports-table th:first-child {
|
||||
border-top-left-radius: 0.75rem;
|
||||
}
|
||||
.reports-table th:last-child {
|
||||
border-top-right-radius: 0.75rem;
|
||||
}
|
||||
.reports-table td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
vertical-align: top;
|
||||
}
|
||||
.reports-table td:first-child {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.reports-table td:last-child {
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
.reports-table tbody tr {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.reports-table tbody tr:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
.reports-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.reports-table .location-cell {
|
||||
color: var(--text-color);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.reports-table .details-cell {
|
||||
color: var(--text-color);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-style: italic;
|
||||
}
|
||||
.reports-table .time-cell {
|
||||
color: var(--text-color);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.reports-table .remaining-cell {
|
||||
color: var(--text-color);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.reports-table .remaining-cell.urgent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
.reports-table .remaining-cell.warning {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background-color: var(--warning-color);
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
.reports-table .remaining-cell.normal {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1.5rem;
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.admin-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-table th, .admin-table td {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
.admin-table th {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
.admin-table th:first-child {
|
||||
border-top-left-radius: 0.75rem;
|
||||
}
|
||||
.admin-table th:last-child {
|
||||
border-top-right-radius: 0.75rem;
|
||||
}
|
||||
.admin-table td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
vertical-align: top;
|
||||
}
|
||||
.admin-table td:first-child {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.admin-table td:last-child {
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
.admin-table tbody tr {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.admin-table tbody tr:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
.admin-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.location-cell {
|
||||
color: var(--text-color);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.details-cell {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.remaining-cell {
|
||||
color: var(--text-color);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.remaining-cell.urgent {
|
||||
color: var(--danger-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.remaining-cell.warning {
|
||||
color: var(--warning-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.reports-table .location-cell,
|
||||
.admin-table .location-cell {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.reports-table .details-cell,
|
||||
.admin-table .details-cell {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
.btn.btn-primary:hover {
|
||||
background-color: rgb(18.5714285714, 76.1428571429, 202.4285714286);
|
||||
}
|
||||
.btn.btn-secondary {
|
||||
background-color: var(--secondary-color);
|
||||
color: white;
|
||||
}
|
||||
.btn.btn-secondary:hover {
|
||||
background-color: rgb(78.6610878661, 91.2468619247, 109.3389121339);
|
||||
}
|
||||
.btn.btn-danger {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
.btn.btn-danger:hover {
|
||||
background-color: rgb(234.9802955665, 21.0197044335, 21.0197044335);
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=style.css.map */
|
266
archive/test-refactored.html
Normal file
266
archive/test-refactored.html
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue