- 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
304 lines
11 KiB
JavaScript
304 lines
11 KiB
JavaScript
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();
|
|
}
|
|
});
|