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
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();
|
||||
}
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue