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

16
.env.example Normal file
View file

@ -0,0 +1,16 @@
# ICE Watch Environment Variables
# Copy this file to .env and fill in your actual values
# MapBox API Configuration (Required for fast geocoding)
# Get your free token at: https://account.mapbox.com/access-tokens/
MAPBOX_ACCESS_TOKEN=pk.your_mapbox_token_here
# Admin Configuration
# Change this to a secure password for admin panel access
ADMIN_PASSWORD=your_secure_password_here
# Optional: Google Maps fallback (if you have it)
# GOOGLE_MAPS_API_KEY=your_google_key_here
# Server Configuration
PORT=3000

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Environment variables
.env
.env.local
.env.production
# Database
*.db
*.sqlite
*.sqlite3
# Node modules
node_modules/
# Logs
*.log
npm-debug.log*
# OS generated files
.DS_Store
Thumbs.db
# IDE files
.vscode/
.idea/
# Temporary files
*.tmp
*.temp

139
README.md Normal file
View file

@ -0,0 +1,139 @@
# ICE Watch Michigan
A community-driven web application for tracking ICE activity locations in Michigan. Reports automatically expire after 24 hours to maintain current information.
## Features
- 🗺️ **Interactive Map** - Real-time location tracking centered on Grand Rapids
- ⚡ **Fast Geocoding** - Lightning-fast address lookup with MapBox API
- 🔄 **Auto-Expiration** - Reports automatically removed after 24 hours
- 👨‍💼 **Admin Panel** - Manage and moderate location reports
- 📱 **Responsive Design** - Works on desktop and mobile devices
- 🔒 **Privacy-Focused** - No user tracking, community safety oriented
## Quick Start
### Prerequisites
- Node.js 18+
- MapBox API token (free tier available)
### Local Development
1. **Clone the repository:**
```bash
git clone https://github.com/yourusername/icewatch.git
cd icewatch
```
2. **Install dependencies:**
```bash
npm install
```
3. **Configure environment variables:**
```bash
cp .env.example .env
# Edit .env with your MapBox token
```
4. **Start the server:**
```bash
npm start
```
5. **Visit the application:**
```
http://localhost:3000
```
## Environment Variables
```bash
# Required for fast geocoding
MAPBOX_ACCESS_TOKEN=pk.your_mapbox_token_here
# Admin panel access
ADMIN_PASSWORD=your_secure_password
# Server configuration
PORT=3000
```
## Deployment
### Automated Deployment (Debian 12 ARM64)
1. **Run the deployment script on your server:**
```bash
curl -sSL https://raw.githubusercontent.com/yourusername/icewatch/main/scripts/deploy.sh | bash
```
2. **Deploy your application:**
```bash
git clone https://github.com/yourusername/icewatch.git /opt/icewatch
cd /opt/icewatch
npm install
```
3. **Configure environment:**
```bash
cp .env.example .env
nano .env # Add your API keys
```
4. **Start services:**
```bash
sudo systemctl enable icewatch
sudo systemctl start icewatch
sudo systemctl enable caddy
sudo systemctl start caddy
```
### Manual Deployment
See `docs/deployment.md` for detailed manual deployment instructions.
## API Endpoints
- `GET /api/locations` - Get active location reports
- `POST /api/locations` - Submit new location report
- `GET /api/config` - Get API configuration
- `GET /admin` - Admin panel (password protected)
## Technology Stack
- **Backend:** Node.js, Express.js, SQLite
- **Frontend:** Vanilla JavaScript, Leaflet.js
- **Geocoding:** MapBox API (with Nominatim fallback)
- **Reverse Proxy:** Caddy (automatic HTTPS)
- **Database:** SQLite (lightweight, serverless)
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request
## Security
- API keys are stored in environment variables
- Admin routes are password protected
- Database queries use parameterized statements
- HTTPS enforced in production
## License
MIT License - see LICENSE file for details
## Support
This is a community safety tool. For issues or questions:
- Create a GitHub issue
- Check existing documentation
- Review security guidelines
---
**⚠️ Safety Notice:** This tool is for community awareness. Always prioritize personal safety and know your rights.

2705
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "icewatch",
"version": "1.0.0",
"description": "ICE location tracking website for Michigan",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.0.1",
"express": "^4.18.2",
"node-cron": "^3.0.3",
"sqlite3": "^5.1.6"
},
"devDependencies": {
"nodemon": "^3.0.1"
},
"keywords": [
"ice",
"tracking",
"michigan",
"map"
],
"author": "Your Name",
"license": "MIT"
}

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;
}

38
scripts/Caddyfile Normal file
View file

@ -0,0 +1,38 @@
# ICE Watch Caddy Configuration
# Replace yourdomain.com with your actual domain
yourdomain.com {
# Reverse proxy to Node.js app
reverse_proxy localhost:3000
# Security headers
header {
# Enable HSTS
Strict-Transport-Security max-age=31536000;
# Prevent clickjacking
X-Frame-Options DENY
# Prevent content type sniffing
X-Content-Type-Options nosniff
# XSS protection
X-XSS-Protection "1; mode=block"
# Referrer policy
Referrer-Policy strict-origin-when-cross-origin
}
# Gzip compression
encode gzip
# Rate limiting (optional)
# rate_limit {
# zone static_ip_10rs {
# key {remote_host}
# events 10
# window 1s
# }
# }
}
# Optional: Redirect www to non-www
www.yourdomain.com {
redir https://yourdomain.com{uri} permanent
}

41
scripts/deploy.sh Normal file
View file

@ -0,0 +1,41 @@
#!/bin/bash
# ICE Watch Deployment Script for Debian 12 ARM64
# Run this script on your server: drone@91.99.139.235
set -e
echo "🚀 Starting ICE Watch deployment..."
# Update system
echo "📦 Updating system packages..."
sudo apt update && sudo apt upgrade -y
# Install Node.js (ARM64 compatible)
echo "📦 Installing Node.js..."
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs build-essential
# Install Caddy for reverse proxy
echo "📦 Installing Caddy..."
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
# Create app directory
echo "📁 Setting up app directory..."
sudo mkdir -p /opt/icewatch
sudo chown $USER:$USER /opt/icewatch
# Navigate to app directory
cd /opt/icewatch
echo "✅ Server setup complete!"
echo "Next steps:"
echo "1. Upload your app files to /opt/icewatch"
echo "2. Run: npm install"
echo "3. Configure your .env file"
echo "4. Set up systemd service"
echo "5. Configure Caddy"

24
scripts/icewatch.service Normal file
View file

@ -0,0 +1,24 @@
[Unit]
Description=ICE Watch Michigan - Community Safety Tool
After=network.target
Wants=network.target
[Service]
Type=simple
User=icewatch
Group=icewatch
WorkingDirectory=/opt/icewatch
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=5
Environment=NODE_ENV=production
# Security settings
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/icewatch
[Install]
WantedBy=multi-user.target

293
server.js Normal file
View file

@ -0,0 +1,293 @@
require('dotenv').config({ path: '.env.local' });
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const cron = require('node-cron');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
// Database setup
const db = new sqlite3.Database('icewatch.db');
console.log('Database connection established');
// Initialize database
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
address TEXT NOT NULL,
latitude REAL,
longitude REAL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => {
if (err) {
console.error('Error initializing database:', err);
} else {
console.log('Database initialized successfully');
}
});
});
// Clean up expired locations (older than 24 hours)
const cleanupExpiredLocations = () => {
console.log('Running cleanup of expired locations');
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
db.run('DELETE FROM locations WHERE created_at < ?', [twentyFourHoursAgo], function(err) {
if (err) {
console.error('Error cleaning up expired locations:', err);
} else {
console.log(`Cleaned up ${this.changes} expired locations`);
}
});
};
// Run cleanup every hour
console.log('Scheduling hourly cleanup task');
cron.schedule('0 * * * *', cleanupExpiredLocations);
// Configuration
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123'; // Change this!
const MAPBOX_ACCESS_TOKEN = process.env.MAPBOX_ACCESS_TOKEN || null; // Set this for better performance
const GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY || null; // Fallback option
const authenticateAdmin = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized' });
}
const token = authHeader.substring(7);
if (token !== ADMIN_PASSWORD) {
return res.status(401).json({ error: 'Invalid credentials' });
}
next();
};
// API Routes
// Get API configuration
app.get('/api/config', (req, res) => {
console.log('📡 API Config requested');
console.log('MapBox token present:', !!MAPBOX_ACCESS_TOKEN);
console.log('MapBox token starts with pk:', MAPBOX_ACCESS_TOKEN?.startsWith('pk.'));
res.json({
mapboxAccessToken: MAPBOX_ACCESS_TOKEN,
hasMapbox: !!MAPBOX_ACCESS_TOKEN,
googleMapsApiKey: GOOGLE_MAPS_API_KEY,
hasGoogleMaps: !!GOOGLE_MAPS_API_KEY
});
});
// Admin login
app.post('/api/admin/login', (req, res) => {
console.log('Admin login attempt');
const { password } = req.body;
if (password === ADMIN_PASSWORD) {
console.log('Admin login successful');
res.json({ token: ADMIN_PASSWORD, message: 'Login successful' });
} else {
console.warn('Admin login failed: invalid password');
res.status(401).json({ error: 'Invalid password' });
}
});
// Get all active locations (within 24 hours)
app.get('/api/locations', (req, res) => {
console.log('Fetching active locations');
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
db.all(
'SELECT * FROM locations WHERE created_at > ? ORDER BY created_at DESC',
[twentyFourHoursAgo],
(err, rows) => {
if (err) {
console.error('Error fetching locations:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
console.log(`Fetched ${rows.length} active locations`);
res.json(rows);
}
);
});
// Add a new location
app.post('/api/locations', (req, res) => {
const { address, latitude, longitude, description } = req.body;
console.log(`Attempt to add new location: ${address}`);
if (!address) {
console.warn('Failed to add location: Address is required');
res.status(400).json({ error: 'Address is required' });
return;
}
db.run(
'INSERT INTO locations (address, latitude, longitude, description) VALUES (?, ?, ?, ?)',
[address, latitude, longitude, description],
function(err) {
if (err) {
console.error('Error inserting location:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
console.log(`Location added successfully: ${address}`);
res.json({
id: this.lastID,
address,
latitude,
longitude,
description,
created_at: new Date().toISOString()
});
}
);
});
// Admin Routes
// Get all locations for admin (including expired ones)
app.get('/api/admin/locations', authenticateAdmin, (req, res) => {
db.all(
'SELECT id, address, description, latitude, longitude, created_at FROM locations ORDER BY created_at DESC',
[],
(err, rows) => {
if (err) {
console.error('Error fetching all locations:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
// Process and clean data before sending
const locations = rows.map(row => ({
id: row.id,
address: row.address,
description: row.description || '',
latitude: row.latitude,
longitude: row.longitude,
created_at: row.created_at,
isActive: new Date(row.created_at) > new Date(Date.now() - 24 * 60 * 60 * 1000)
}));
res.json(locations);
}
);
});
// Update a location (admin only)
app.put('/api/admin/locations/:id', authenticateAdmin, (req, res) => {
const { id } = req.params;
const { address, latitude, longitude, description } = req.body;
if (!address) {
res.status(400).json({ error: 'Address is required' });
return;
}
db.run(
'UPDATE locations SET address = ?, latitude = ?, longitude = ?, description = ? WHERE id = ?',
[address, latitude, longitude, description, id],
function(err) {
if (err) {
console.error('Error updating location:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location updated successfully' });
}
);
});
// Delete a location (admin authentication required)
app.delete('/api/admin/locations/:id', authenticateAdmin, (req, res) => {
const { id } = req.params;
db.run('DELETE FROM locations WHERE id = ?', [id], function(err) {
if (err) {
console.error('Error deleting location:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location deleted successfully' });
});
});
// Legacy delete route (keeping for backwards compatibility)
app.delete('/api/locations/:id', (req, res) => {
const { id } = req.params;
db.run('DELETE FROM locations WHERE id = ?', [id], function(err) {
if (err) {
console.error('Error deleting location:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location deleted successfully' });
});
});
// Serve the main page
app.get('/', (req, res) => {
console.log('Serving the main page');
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Serve the admin page
app.get('/admin', (req, res) => {
console.log('Serving the admin page');
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
});
// Start server
app.listen(PORT, () => {
console.log('=======================================');
console.log('ICE Watch server successfully started');
console.log(`Listening on port ${PORT}`);
console.log(`Visit http://localhost:${PORT} to view the website`);
console.log('=======================================');
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\nShutting down server...');
db.close((err) => {
if (err) {
console.error('Error closing database:', err);
} else {
console.log('Database connection closed.');
}
process.exit(0);
});
});