ice/public/admin.js
Deco Vander c5356b611a Fix map display and auto theme detection
- Add missing #map CSS styles to fix map container display
- Fix auto theme detection by implementing proper system preference detection
- Update applyTheme function in all JS files to handle 'auto' theme correctly
- System theme changes are now properly detected and applied when in auto mode
2025-07-04 16:36:41 -04:00

729 lines
27 KiB
JavaScript

document.addEventListener('DOMContentLoaded', () => {
let authToken = localStorage.getItem('adminToken');
let allLocations = [];
let editingRowId = null;
let sessionTimeout = null;
let warningTimeout = null;
// Session timeout settings (in milliseconds)
const SESSION_DURATION = 30 * 60 * 1000; // 30 minutes
const WARNING_TIME = 5 * 60 * 1000; // Show warning 5 minutes before expiry
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 and session is still valid
if (authToken) {
const loginTime = localStorage.getItem('adminLoginTime');
const now = Date.now();
if (loginTime && (now - parseInt(loginTime)) < SESSION_DURATION) {
showAdminSection();
loadLocations();
startSessionTimer();
} else {
// Session expired
logout('Session expired. Please log in again.');
}
}
// 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);
localStorage.setItem('adminLoginTime', Date.now().toString());
showAdminSection();
loadLocations();
startSessionTimer();
} else {
showMessage(loginMessage, data.error, 'error');
}
} catch (error) {
showMessage(loginMessage, 'Login failed. Please try again.', 'error');
}
});
// Logout handler
logoutBtn.addEventListener('click', () => {
logout('Logged out successfully.');
});
// Refresh button handler
refreshBtn.addEventListener('click', () => {
loadLocations();
});
// Tab navigation logic
const tabs = document.querySelectorAll('.tab-btn');
const contents = document.querySelectorAll('.tab-content');
tabs.forEach(tab => {
tab.addEventListener('click', function () {
tabs.forEach(t => t.classList.remove('active'));
contents.forEach(c => c.classList.remove('active'));
this.classList.add('active');
document.querySelector(`#${this.dataset.tab}-tab`).classList.add('active');
// Load data for the active tab
if (this.dataset.tab === 'profanity') {
loadProfanityWords();
}
});
});
// Profanity management handlers
const addProfanityForm = document.getElementById('add-profanity-form');
const testProfanityForm = document.getElementById('test-profanity-form');
const profanityTableBody = document.getElementById('profanity-tbody');
if (addProfanityForm) {
addProfanityForm.addEventListener('submit', async (e) => {
e.preventDefault();
const word = document.getElementById('new-word').value.trim();
const severity = document.getElementById('new-severity').value;
const category = document.getElementById('new-category').value.trim() || 'custom';
if (!word) return;
await addProfanityWord(word, severity, category);
});
}
if (testProfanityForm) {
testProfanityForm.addEventListener('submit', async (e) => {
e.preventDefault();
const text = document.getElementById('test-text').value.trim();
if (!text) return;
await testProfanityFilter(text);
});
}
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';
}
// Session management functions
function startSessionTimer() {
clearSessionTimers();
// Set warning timer (5 minutes before expiry)
warningTimeout = setTimeout(() => {
showSessionWarning();
}, SESSION_DURATION - WARNING_TIME);
// Set logout timer (30 minutes)
sessionTimeout = setTimeout(() => {
logout('Session expired due to inactivity.');
}, SESSION_DURATION);
}
function clearSessionTimers() {
if (sessionTimeout) {
clearTimeout(sessionTimeout);
sessionTimeout = null;
}
if (warningTimeout) {
clearTimeout(warningTimeout);
warningTimeout = null;
}
}
function showSessionWarning() {
const extend = confirm('Your admin session will expire in 5 minutes. Click OK to extend your session, or Cancel to log out now.');
if (extend) {
extendSession();
} else {
logout('Session ended by user.');
}
}
function extendSession() {
// Update login time and restart timers
localStorage.setItem('adminLoginTime', Date.now().toString());
startSessionTimer();
console.log('Admin session extended for 30 minutes');
}
function logout(message = 'Logged out.') {
clearSessionTimers();
authToken = null;
localStorage.removeItem('adminToken');
localStorage.removeItem('adminLoginTime');
showLoginSection();
if (message) {
showMessage(loginMessage, message, 'error');
}
}
// Reset session timer on user activity
function resetSessionTimer() {
if (authToken && adminSection.style.display !== 'none') {
const loginTime = localStorage.getItem('adminLoginTime');
const now = Date.now();
// Only reset if we're within the session duration
if (loginTime && (now - parseInt(loginTime)) < SESSION_DURATION) {
localStorage.setItem('adminLoginTime', now.toString());
startSessionTimer();
}
}
}
// Listen for user activity to reset session timer
const activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click'];
let activityTimer = null;
activityEvents.forEach(event => {
document.addEventListener(event, () => {
// Throttle activity detection to once per minute
if (!activityTimer) {
activityTimer = setTimeout(() => {
resetSessionTimer();
activityTimer = null;
}, 60000); // 1 minute throttle
}
}, true);
});
// Check session validity periodically
setInterval(() => {
if (authToken) {
const loginTime = localStorage.getItem('adminLoginTime');
const now = Date.now();
if (!loginTime || (now - parseInt(loginTime)) >= SESSION_DURATION) {
logout('Session expired.');
}
}
}, 60000); // Check every minute
async function loadLocations() {
try {
const response = await fetch('/api/admin/locations', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.status === 401) {
// Token expired or invalid
logout('Session expired. Please log in again.');
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.');
}
};
// getTimeAgo and parseUTCDate functions are now available from utils.js
// Profanity management functions
async function loadProfanityWords() {
if (!profanityTableBody) return;
try {
const response = await fetch('/api/admin/profanity-words', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.status === 401) {
logout('Session expired. Please log in again.');
return;
}
const data = await response.json();
if (response.ok) {
displayProfanityWords(data || []);
} else {
console.error('Failed to load profanity words:', data.error);
profanityTableBody.innerHTML = '<tr><td colspan="6">Failed to load words</td></tr>';
}
} catch (error) {
console.error('Error loading profanity words:', error);
profanityTableBody.innerHTML = '<tr><td colspan="6">Error loading words</td></tr>';
}
}
function displayProfanityWords(words) {
if (!profanityTableBody) return;
if (words.length === 0) {
profanityTableBody.innerHTML = '<tr><td colspan="6">No custom words added yet</td></tr>';
return;
}
profanityTableBody.innerHTML = words.map(word => `
<tr>
<td>${word.id}</td>
<td><code>${escapeHtml(word.word)}</code></td>
<td><span class="severity-${word.severity}">${word.severity}</span></td>
<td>${word.category || 'N/A'}</td>
<td>${new Date(word.created_at).toLocaleDateString()}</td>
<td>
<button class="action-btn danger" onclick="deleteProfanityWord(${word.id})">
🗑️ Delete
</button>
</td>
</tr>
`).join('');
}
async function addProfanityWord(word, severity, category) {
try {
const response = await fetch('/api/admin/profanity-words', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ word, severity, category })
});
const data = await response.json();
if (response.ok) {
// Clear form
document.getElementById('new-word').value = '';
document.getElementById('new-category').value = 'custom';
document.getElementById('new-severity').value = 'medium';
// Reload words list
loadProfanityWords();
console.log('Word added successfully');
} else {
alert('Failed to add word: ' + data.error);
}
} catch (error) {
console.error('Error adding profanity word:', error);
alert('Error adding word. Please try again.');
}
}
async function testProfanityFilter(text) {
const resultsDiv = document.getElementById('test-results');
if (!resultsDiv) return;
try {
const response = await fetch('/api/admin/test-profanity', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ text })
});
const data = await response.json();
if (response.ok) {
if (data.analysis && data.analysis.hasProfanity) {
resultsDiv.className = 'test-results profane';
resultsDiv.innerHTML = `
<strong>⚠️ Profanity Detected!</strong><br>
Detected words: ${data.analysis.matches.map(m => `<code>${escapeHtml(m.word)}</code>`).join(', ')}<br>
Severity: ${data.analysis.severity}<br>
Filtered text: "${escapeHtml(data.filtered)}"
`;
} else {
resultsDiv.className = 'test-results clean';
resultsDiv.innerHTML = '<strong>✅ Text is clean!</strong><br>No profanity detected.';
}
} else {
resultsDiv.className = 'test-results empty';
resultsDiv.innerHTML = 'Error testing text: ' + data.error;
}
} catch (error) {
console.error('Error testing profanity filter:', error);
resultsDiv.className = 'test-results empty';
resultsDiv.innerHTML = 'Error testing text. Please try again.';
}
}
// Make deleteProfanityWord available globally for onclick handlers
window.deleteProfanityWord = async function(wordId) {
if (!confirm('Are you sure you want to delete this word?')) return;
try {
const response = await fetch(`/api/admin/profanity-words/${wordId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.ok) {
loadProfanityWords();
console.log('Word deleted successfully');
} else {
const data = await response.json();
alert('Failed to delete word: ' + data.error);
}
} catch (error) {
console.error('Error deleting profanity word:', error);
alert('Error deleting word. Please try again.');
}
};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 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) {
if (theme === 'auto') {
// Detect system preference and apply appropriate theme
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
} else {
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;
}
}