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:
commit
edfdeb5117
16 changed files with 5323 additions and 0 deletions
16
.env.example
Normal file
16
.env.example
Normal 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
28
.gitignore
vendored
Normal 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
139
README.md
Normal 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
2705
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
package.json
Normal file
28
package.json
Normal 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
228
public/admin.html
Normal 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
304
public/admin.js
Normal 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
433
public/app-google.js
Normal 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
431
public/app-mapbox.js
Normal 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
408
public/app.js
Normal 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
70
public/index.html
Normal 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
137
public/style.css
Normal 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
38
scripts/Caddyfile
Normal 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
41
scripts/deploy.sh
Normal 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
24
scripts/icewatch.service
Normal 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
293
server.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Add table
Add a link
Reference in a new issue