Initial commit: ICE Watch Michigan community safety tool

- Node.js/Express backend with SQLite database
- Interactive map with real-time location tracking
- MapBox API integration for fast geocoding
- Admin panel for content moderation
- 24-hour auto-expiring reports
- Deployment scripts for Debian 12 ARM64
- Caddy reverse proxy with automatic HTTPS
This commit is contained in:
Deco Vander 2025-07-02 23:27:22 -04:00
commit edfdeb5117
16 changed files with 5323 additions and 0 deletions

228
public/admin.html Normal file
View file

@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ICE Watch Admin</title>
<link rel="stylesheet" href="style.css">
<style>
.admin-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.login-section {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
max-width: 400px;
margin: 50px auto;
}
.admin-section {
display: none;
}
.locations-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.locations-table th,
.locations-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.locations-table th {
background-color: #f8f9fa;
font-weight: bold;
}
.locations-table tr:hover {
background-color: #f5f5f5;
}
.action-buttons {
display: flex;
gap: 5px;
}
.btn {
padding: 5px 10px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
text-decoration: none;
display: inline-block;
}
.btn-edit {
background-color: #007bff;
color: white;
}
.btn-delete {
background-color: #dc3545;
color: white;
}
.btn-save {
background-color: #28a745;
color: white;
}
.btn-cancel {
background-color: #6c757d;
color: white;
}
.edit-row {
background-color: #fff3cd !important;
}
.edit-input {
width: 100%;
padding: 4px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 12px;
}
.status-indicator {
padding: 2px 6px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
}
.status-active {
background-color: #d4edda;
color: #155724;
}
.status-expired {
background-color: #f8d7da;
color: #721c24;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.logout-btn {
background-color: #dc3545;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
.stat-number {
font-size: 2em;
font-weight: bold;
color: #007bff;
}
.address-cell {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
</head>
<body>
<div class="admin-container">
<!-- Login Section -->
<div id="login-section" class="login-section">
<h2>🔐 Admin Login</h2>
<form id="login-form">
<div class="form-group">
<label for="password">Admin Password:</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Login</button>
</form>
<div id="login-message" class="message"></div>
</div>
<!-- Admin Section -->
<div id="admin-section" class="admin-section">
<div class="admin-header">
<h1>🚨 ICE Watch Admin Panel</h1>
<div>
<button id="refresh-btn" class="btn btn-edit">Refresh Data</button>
<button id="logout-btn" class="logout-btn">Logout</button>
</div>
</div>
<div class="stats">
<div class="stat-card">
<div class="stat-number" id="total-count">0</div>
<div>Total Reports</div>
</div>
<div class="stat-card">
<div class="stat-number" id="active-count">0</div>
<div>Active (24hrs)</div>
</div>
<div class="stat-card">
<div class="stat-number" id="expired-count">0</div>
<div>Expired</div>
</div>
</div>
<div class="form-section">
<h3>All Location Reports</h3>
<table class="locations-table">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Address</th>
<th>Description</th>
<th>Reported</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="locations-tbody">
<tr>
<td colspan="6">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<script src="admin.js"></script>
</body>
</html>

304
public/admin.js Normal file
View file

@ -0,0 +1,304 @@
document.addEventListener('DOMContentLoaded', () => {
let authToken = localStorage.getItem('adminToken');
let allLocations = [];
let editingRowId = null;
const loginSection = document.getElementById('login-section');
const adminSection = document.getElementById('admin-section');
const loginForm = document.getElementById('login-form');
const loginMessage = document.getElementById('login-message');
const logoutBtn = document.getElementById('logout-btn');
const refreshBtn = document.getElementById('refresh-btn');
const locationsTableBody = document.getElementById('locations-tbody');
// Check if already logged in
if (authToken) {
showAdminSection();
loadLocations();
}
// Login form handler
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await response.json();
if (response.ok) {
authToken = data.token;
localStorage.setItem('adminToken', authToken);
showAdminSection();
loadLocations();
} else {
showMessage(loginMessage, data.error, 'error');
}
} catch (error) {
showMessage(loginMessage, 'Login failed. Please try again.', 'error');
}
});
// Logout handler
logoutBtn.addEventListener('click', () => {
authToken = null;
localStorage.removeItem('adminToken');
showLoginSection();
});
// Refresh button handler
refreshBtn.addEventListener('click', () => {
loadLocations();
});
function showLoginSection() {
loginSection.style.display = 'block';
adminSection.style.display = 'none';
document.getElementById('password').value = '';
hideMessage(loginMessage);
}
function showAdminSection() {
loginSection.style.display = 'none';
adminSection.style.display = 'block';
}
function showMessage(element, message, type) {
element.textContent = message;
element.className = `message ${type}`;
element.style.display = 'block';
setTimeout(() => hideMessage(element), 5000);
}
function hideMessage(element) {
element.style.display = 'none';
}
async function loadLocations() {
try {
const response = await fetch('/api/admin/locations', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.status === 401) {
// Token expired or invalid
authToken = null;
localStorage.removeItem('adminToken');
showLoginSection();
return;
}
if (!response.ok) {
throw new Error('Failed to load locations');
}
allLocations = await response.json();
updateStats();
renderLocationsTable();
} catch (error) {
console.error('Error loading locations:', error);
locationsTableBody.innerHTML = '<tr><td colspan="6">Error loading locations</td></tr>';
}
}
function updateStats() {
const now = new Date();
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const activeLocations = allLocations.filter(location =>
new Date(location.created_at) > twentyFourHoursAgo
);
document.getElementById('total-count').textContent = allLocations.length;
document.getElementById('active-count').textContent = activeLocations.length;
document.getElementById('expired-count').textContent = allLocations.length - activeLocations.length;
}
function renderLocationsTable() {
if (allLocations.length === 0) {
locationsTableBody.innerHTML = '<tr><td colspan="6">No locations found</td></tr>';
return;
}
const now = new Date();
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
locationsTableBody.innerHTML = allLocations.map(location => {
const isActive = new Date(location.created_at) > twentyFourHoursAgo;
const createdDate = new Date(location.created_at);
const formattedDate = createdDate.toLocaleString();
return `
<tr data-id="${location.id}" ${editingRowId === location.id ? 'class="edit-row"' : ''}>
<td>${location.id}</td>
<td>
<span class="status-indicator ${isActive ? 'status-active' : 'status-expired'}">
${isActive ? 'ACTIVE' : 'EXPIRED'}
</span>
</td>
<td class="address-cell" title="${location.address}">${location.address}</td>
<td>${location.description || ''}</td>
<td title="${formattedDate}">${getTimeAgo(location.created_at)}</td>
<td>
<div class="action-buttons">
<button class="btn btn-edit" onclick="editLocation(${location.id})">Edit</button>
<button class="btn btn-delete" onclick="deleteLocation(${location.id})">Delete</button>
</div>
</td>
</tr>
`;
}).join('');
}
function renderEditRow(location) {
const row = document.querySelector(`tr[data-id="${location.id}"]`);
const now = new Date();
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const isActive = new Date(location.created_at) > twentyFourHoursAgo;
const createdDate = new Date(location.created_at);
const formattedDate = createdDate.toLocaleString();
row.innerHTML = `
<td>${location.id}</td>
<td>
<span class="status-indicator ${isActive ? 'status-active' : 'status-expired'}">
${isActive ? 'ACTIVE' : 'EXPIRED'}
</span>
</td>
<td>
<input type="text" class="edit-input" id="edit-address-${location.id}"
value="${location.address}" placeholder="Address">
</td>
<td>
<input type="text" class="edit-input" id="edit-description-${location.id}"
value="${location.description || ''}" placeholder="Description">
</td>
<td title="${formattedDate}">${getTimeAgo(location.created_at)}</td>
<td>
<div class="action-buttons">
<button class="btn btn-save" onclick="saveLocation(${location.id})">Save</button>
<button class="btn btn-cancel" onclick="cancelEdit()">Cancel</button>
</div>
</td>
`;
row.className = 'edit-row';
}
window.editLocation = (id) => {
if (editingRowId) {
cancelEdit();
}
editingRowId = id;
const location = allLocations.find(loc => loc.id === id);
if (location) {
renderEditRow(location);
}
};
window.cancelEdit = () => {
editingRowId = null;
renderLocationsTable();
};
window.saveLocation = async (id) => {
const address = document.getElementById(`edit-address-${id}`).value.trim();
const description = document.getElementById(`edit-description-${id}`).value.trim();
if (!address) {
alert('Address is required');
return;
}
try {
// Geocode the address if it was changed
let latitude = null;
let longitude = null;
const originalLocation = allLocations.find(loc => loc.id === id);
if (address !== originalLocation.address) {
try {
const geoResponse = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}`);
const geoData = await geoResponse.json();
if (geoData && geoData.length > 0) {
latitude = parseFloat(geoData[0].lat);
longitude = parseFloat(geoData[0].lon);
}
} catch (geoError) {
console.warn('Geocoding failed, keeping original coordinates');
latitude = originalLocation.latitude;
longitude = originalLocation.longitude;
}
} else {
latitude = originalLocation.latitude;
longitude = originalLocation.longitude;
}
const response = await fetch(`/api/admin/locations/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ address, latitude, longitude, description })
});
if (response.ok) {
editingRowId = null;
loadLocations(); // Reload to get updated data
} else {
const error = await response.json();
alert(`Error updating location: ${error.error}`);
}
} catch (error) {
console.error('Error saving location:', error);
alert('Error saving location. Please try again.');
}
};
window.deleteLocation = async (id) => {
if (!confirm('Are you sure you want to delete this location report?')) {
return;
}
try {
const response = await fetch(`/api/admin/locations/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.ok) {
loadLocations(); // Reload the table
} else {
const error = await response.json();
alert(`Error deleting location: ${error.error}`);
}
} catch (error) {
console.error('Error deleting location:', error);
alert('Error deleting location. Please try again.');
}
};
function getTimeAgo(timestamp) {
const now = new Date();
const reportTime = new Date(timestamp);
const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60));
if (diffInMinutes < 1) return 'just now';
if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) return `${diffInDays} day${diffInDays !== 1 ? 's' : ''} ago`;
return reportTime.toLocaleDateString();
}
});

433
public/app-google.js Normal file
View file

@ -0,0 +1,433 @@
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);
}
});
};
const getTimeAgo = (timestamp) => {
const now = new Date();
const reportTime = new Date(timestamp);
const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60));
if (diffInMinutes < 1) return 'just now';
if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
return 'over a day ago';
};
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);
}
});
});

431
public/app-mapbox.js Normal file
View file

@ -0,0 +1,431 @@
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 = { 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');
let autocompleteTimeout;
let selectedIndex = -1;
let autocompleteResults = [];
let currentMarkers = [];
let updateInterval;
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);
}
});
};
const getTimeAgo = (timestamp) => {
const now = new Date();
const reportTime = new Date(timestamp);
const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60));
if (diffInMinutes < 1) return 'just now';
if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
return 'over a day ago';
};
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);
// 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 => 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
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();
}
});
window.addEventListener('beforeunload', () => {
if (updateInterval) {
clearInterval(updateInterval);
}
});
});

408
public/app.js Normal file
View file

@ -0,0 +1,408 @@
document.addEventListener('DOMContentLoaded', () => {
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);
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;
const clearMarkers = () => {
currentMarkers.forEach(marker => {
map.removeLayer(marker);
});
currentMarkers = [];
};
const showMarkers = locations => {
// Clear existing markers first
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);
}
});
};
const getTimeAgo = (timestamp) => {
const now = new Date();
const reportTime = new Date(timestamp);
const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60));
if (diffInMinutes < 1) return 'just now';
if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
return 'over a day ago';
};
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' : ''}`;
// Add visual indicator of last update
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';
});
};
// Initial load
refreshLocations();
// Set up real-time updates every 30 seconds
updateInterval = setInterval(refreshLocations, 30000);
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';
// Try multiple geocoding strategies for intersections
const tryGeocode = async (query) => {
const cleanQuery = query.trim();
const startTime = performance.now();
console.log(`🔍 Starting geocoding for: "${cleanQuery}"`);
// Generate multiple search variations
const searches = [
cleanQuery, // Original query
cleanQuery.replace(' & ', ' and '), // Replace & with 'and'
cleanQuery.replace(' and ', ' & '), // Replace 'and' with &
cleanQuery.replace(' at ', ' & '), // Replace 'at' with &
`${cleanQuery}, Michigan`, // Add Michigan if not present
`${cleanQuery}, 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)];
console.log(`📋 Generated ${uniqueSearches.length} search variations`);
let attemptCount = 0;
for (const searchQuery of uniqueSearches) {
attemptCount++;
const queryStartTime = performance.now();
try {
console.log(`🌐 Attempt ${attemptCount}: "${searchQuery}"`);
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&extratags=1&q=${encodeURIComponent(searchQuery)}`);
const data = await response.json();
const queryTime = (performance.now() - queryStartTime).toFixed(2);
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 result = michiganResults.length > 0 ? michiganResults[0] : data[0];
const totalTime = (performance.now() - startTime).toFixed(2);
console.log(`✅ Geocoding successful in ${totalTime}ms (query: ${queryTime}ms)`);
console.log(`📍 Found: ${result.display_name}`);
console.log(`🎯 Coordinates: ${result.lat}, ${result.lon}`);
return result;
} else {
console.log(`❌ No results found (${queryTime}ms)`);
}
} catch (error) {
const queryTime = (performance.now() - queryStartTime).toFixed(2);
console.warn(`🚫 Geocoding failed for: "${searchQuery}" (${queryTime}ms)`, error);
}
}
// If intersection search fails, try individual streets for approximate location
if (cleanQuery.includes('&') || cleanQuery.includes(' and ')) {
console.log(`🔄 Trying fallback strategy for intersection`);
const streets = cleanQuery.split(/\s+(?:&|and)\s+/i);
if (streets.length >= 2) {
const firstStreet = streets[0].trim() + ', Grand Rapids, MI';
const fallbackStartTime = performance.now();
try {
console.log(`🌐 Fallback: "${firstStreet}"`);
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(firstStreet)}`);
const data = await response.json();
const fallbackTime = (performance.now() - fallbackStartTime).toFixed(2);
if (data && data.length > 0) {
const totalTime = (performance.now() - startTime).toFixed(2);
console.log(`⚠️ Using approximate location from first street (${totalTime}ms total)`);
console.log(`📍 Approximate: ${data[0].display_name}`);
return data[0];
} else {
console.log(`❌ Fallback failed - no results (${fallbackTime}ms)`);
}
} catch (error) {
const fallbackTime = (performance.now() - fallbackStartTime).toFixed(2);
console.warn(`🚫 Fallback search failed: "${firstStreet}" (${fallbackTime}ms)`, error);
}
}
}
const totalTime = (performance.now() - startTime).toFixed(2);
console.error(`💥 All geocoding strategies failed after ${totalTime}ms`);
throw new Error('Address not found with any search strategy');
};
tryGeocode(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 => {
// Immediately refresh all locations to show the new one
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 functionality
const fetchAddressSuggestions = async (query) => {
if (query.length < 3) {
hideAutocomplete();
return;
}
try {
// Create search variations for better intersection handling
const searchQueries = [
query,
query.replace(' & ', ' and '),
query.replace(' and ', ' & '),
`${query}, Grand Rapids, MI`,
`${query}, Michigan`
];
let allResults = [];
// Try each search variation
for (const searchQuery of searchQueries) {
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=3&countrycodes=us&addressdetails=1`
);
const data = await response.json();
if (data && data.length > 0) {
allResults = allResults.concat(data);
}
} catch (error) {
console.warn(`Autocomplete search failed for: ${searchQuery}`);
}
}
// Filter and deduplicate results
const michiganResults = allResults.filter(item => {
return item.display_name.toLowerCase().includes('michigan') ||
item.display_name.toLowerCase().includes(', mi,') ||
item.display_name.toLowerCase().includes('grand rapids');
});
// Remove duplicates based on display_name
const uniqueResults = michiganResults.filter((item, index, arr) =>
arr.findIndex(other => other.display_name === item.display_name) === index
);
autocompleteResults = uniqueResults.slice(0, 5);
showAutocomplete(autocompleteResults);
} catch (error) {
console.error('Error fetching address suggestions:', error);
hideAutocomplete();
}
};
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;
// Remove previous selection
items[selectedIndex]?.classList.remove('selected');
// Update index
if (direction === 'down') {
selectedIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0;
} else {
selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
}
// Add new selection
items[selectedIndex].classList.add('selected');
items[selectedIndex].scrollIntoView({ block: 'nearest' });
};
// Event listeners for autocomplete
addressInput.addEventListener('input', (e) => {
clearTimeout(autocompleteTimeout);
const query = e.target.value.trim();
if (query.length < 3) {
hideAutocomplete();
return;
}
// Debounce the API calls
autocompleteTimeout = setTimeout(() => {
fetchAddressSuggestions(query);
}, 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;
}
});
// Hide autocomplete when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.autocomplete-container')) {
hideAutocomplete();
}
});
// Cleanup interval when page is unloaded
window.addEventListener('beforeunload', () => {
if (updateInterval) {
clearInterval(updateInterval);
}
});
});

70
public/index.html Normal file
View file

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ICE Watch Michigan</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1>🚨 ICE Watch Michigan</h1>
<p>Community-reported ICE activity locations (auto-expire after 24 hours)</p>
</header>
<div class="content">
<div class="form-section">
<h2>Report ICE Activity</h2>
<form id="location-form">
<div class="form-group">
<label for="address">Address or Location *</label>
<div class="autocomplete-container">
<input type="text" id="address" name="address" required
placeholder="Enter address, intersection (e.g., Main St & Second St, City), or landmark"
autocomplete="off">
<div id="autocomplete-list" class="autocomplete-list"></div>
</div>
<small class="input-help">Examples: "123 Main St, City" or "Main St & Oak Ave, City" or "CVS Pharmacy, City"</small>
</div>
<div class="form-group">
<label for="description">Additional Details (Optional)</label>
<textarea id="description" name="description" rows="3"
placeholder="Number of vehicles, time observed, etc."></textarea>
</div>
<button type="submit" id="submit-btn">
<span id="submit-text">Report Location</span>
<span id="submit-loading" style="display: none;">Submitting...</span>
</button>
</form>
<div id="message" class="message"></div>
</div>
<div class="map-section">
<h2>Current Reports</h2>
<div id="map"></div>
<div class="map-info">
<p><strong>🔴 Red markers:</strong> ICE activity reported</p>
<p><strong>⏰ Auto-cleanup:</strong> Reports disappear after 24 hours</p>
<p id="location-count">Loading locations...</p>
</div>
</div>
</div>
<footer>
<p><strong>Safety Notice:</strong> This is a community tool for awareness. Stay safe and know your rights.</p>
<div class="disclaimer">
<small>This website is for informational purposes only. Verify information independently.
Reports are automatically deleted after 24 hours.</small>
</div>
</footer>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="app-mapbox.js"></script>
</body>
</html>

137
public/style.css Normal file
View file

@ -0,0 +1,137 @@
body {
font-family: Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background-color: #f4f4f9;
}
.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: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.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 #ddd;
border-radius: 4px;
}
.autocomplete-container {
position: relative;
width: 100%;
}
.autocomplete-list {
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-item {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #eee;
}
.autocomplete-item:hover,
.autocomplete-item.selected {
background-color: #f8f9fa;
}
.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: #007bff;
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: #0056b3;
}
.message {
margin-top: 10px;
padding: 10px;
display: none;
border-radius: 4px;
}
.success {
background-color: #d4edda;
color: #155724;
}
.error {
background-color: #f8d7da;
color: #721c24;
}
footer {
text-align: center;
padding: 30px 20px;
margin-top: 30px;
border-top: 1px solid #ddd;
clear: both;
}
disclaimer {
font-size: 0.8em;
color: #777;
}