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