- Updated all static asset URLs to use iceymi.b-cdn.net CDN - Changed favicon, CSS, and JS file references in index.html, admin.html, and privacy.html - API calls remain pointed to origin server for dynamic content - Ready for CDN deployment with proper cache separation
430 lines
16 KiB
JavaScript
430 lines
16 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="7">Error loading locations</td></tr>';
|
|
}
|
|
}
|
|
|
|
function updateStats() {
|
|
const now = new Date();
|
|
const fortyEightHoursAgo = new Date(now.getTime() - 48 * 60 * 60 * 1000);
|
|
|
|
const activeLocations = allLocations.filter(location =>
|
|
new Date(location.created_at) > fortyEightHoursAgo
|
|
);
|
|
|
|
const persistentLocations = allLocations.filter(location => location.persistent);
|
|
|
|
document.getElementById('total-count').textContent = allLocations.length;
|
|
document.getElementById('active-count').textContent = activeLocations.length;
|
|
document.getElementById('expired-count').textContent = allLocations.length - activeLocations.length;
|
|
document.getElementById('persistent-count').textContent = persistentLocations.length;
|
|
}
|
|
|
|
function renderLocationsTable() {
|
|
if (allLocations.length === 0) {
|
|
locationsTableBody.innerHTML = '<tr><td colspan="7">No locations found</td></tr>';
|
|
return;
|
|
}
|
|
|
|
const now = new Date();
|
|
const fortyEightHoursAgo = new Date(now.getTime() - 48 * 60 * 60 * 1000);
|
|
|
|
locationsTableBody.innerHTML = allLocations.map(location => {
|
|
const isActive = new Date(location.created_at) > fortyEightHoursAgo;
|
|
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>
|
|
<button class="btn ${location.persistent ? 'btn-save' : 'btn-edit'}"
|
|
onclick="togglePersistent(${location.id})"
|
|
title="${location.persistent ? 'Remove persistent status' : 'Mark as persistent'}">
|
|
${location.persistent ? '🔒 Yes' : '🔓 No'}
|
|
</button>
|
|
</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 fortyEightHoursAgo = new Date(now.getTime() - 48 * 60 * 60 * 1000);
|
|
const isActive = new Date(location.created_at) > fortyEightHoursAgo;
|
|
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>
|
|
<button class="btn ${location.persistent ? 'btn-save' : 'btn-edit'}"
|
|
onclick="togglePersistent(${location.id})"
|
|
title="${location.persistent ? 'Remove persistent status' : 'Mark as persistent'}">
|
|
${location.persistent ? '🔒 Yes' : '🔓 No'}
|
|
</button>
|
|
</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.togglePersistent = async (id) => {
|
|
const location = allLocations.find(loc => loc.id === id);
|
|
if (!location) return;
|
|
|
|
const newPersistentStatus = !location.persistent;
|
|
const confirmMessage = newPersistentStatus
|
|
? 'Mark this report as persistent? It will not auto-expire after 48 hours.'
|
|
: 'Remove persistent status? This report will expire normally after 48 hours.';
|
|
|
|
if (!confirm(confirmMessage)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/admin/locations/${id}/persistent`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: JSON.stringify({ persistent: newPersistentStatus })
|
|
});
|
|
|
|
if (response.ok) {
|
|
loadLocations(); // Reload to get updated data
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`Error updating persistent status: ${error.error}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling persistent status:', error);
|
|
alert('Error updating persistent status. 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();
|
|
}
|
|
|
|
// Initialize theme toggle
|
|
initializeTheme();
|
|
});
|
|
|
|
// Theme toggle functionality (shared with main page)
|
|
function initializeTheme() {
|
|
const themeToggle = document.getElementById('theme-toggle');
|
|
const themeIcon = document.querySelector('.theme-icon');
|
|
|
|
if (!themeToggle || !themeIcon) {
|
|
console.warn('Theme toggle elements not found');
|
|
return;
|
|
}
|
|
|
|
// Check for saved theme preference or default to auto (follows system)
|
|
const savedTheme = localStorage.getItem('theme') || 'auto';
|
|
applyTheme(savedTheme);
|
|
|
|
// Update icon based on current theme
|
|
updateThemeIcon(savedTheme, themeIcon);
|
|
|
|
// Add click listener for cycling through themes
|
|
themeToggle.addEventListener('click', () => {
|
|
const currentTheme = localStorage.getItem('theme') || 'auto';
|
|
let newTheme;
|
|
|
|
// Cycle: auto → light → dark → auto
|
|
switch(currentTheme) {
|
|
case 'auto':
|
|
newTheme = 'light';
|
|
break;
|
|
case 'light':
|
|
newTheme = 'dark';
|
|
break;
|
|
case 'dark':
|
|
newTheme = 'auto';
|
|
break;
|
|
default:
|
|
newTheme = 'auto';
|
|
}
|
|
|
|
localStorage.setItem('theme', newTheme);
|
|
applyTheme(newTheme);
|
|
updateThemeIcon(newTheme, themeIcon);
|
|
|
|
console.log(`Theme switched to: ${newTheme}`);
|
|
});
|
|
|
|
// Listen for system theme changes when in auto mode
|
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
const currentTheme = localStorage.getItem('theme') || 'auto';
|
|
if (currentTheme === 'auto') {
|
|
applyTheme('auto');
|
|
}
|
|
});
|
|
}
|
|
|
|
function applyTheme(theme) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
}
|
|
|
|
function updateThemeIcon(theme, iconElement) {
|
|
switch(theme) {
|
|
case 'auto':
|
|
iconElement.textContent = '🌍'; // Globe (auto)
|
|
break;
|
|
case 'light':
|
|
iconElement.textContent = '☀️'; // Sun (light)
|
|
break;
|
|
case 'dark':
|
|
iconElement.textContent = '🌙'; // Moon (dark)
|
|
break;
|
|
}
|
|
}
|