ice/public/admin.js
Deco Vander a063d5a2c9 Create shared utility module to eliminate function duplication
- Create public/utils.js with shared frontend utility functions
- Extract parseUTCDate, getTimeAgo, getTimeRemaining, getRemainingClass to utils.js
- Remove duplicate functions from admin.js, app-mapbox.js, app-google.js, and app.js
- Add utils.js script import to index.html and admin.html
- Add comprehensive JSDoc documentation for all utility functions
- Ensure consistent UTC timestamp parsing across all frontend scripts

This addresses Copilot AI feedback about function duplication across multiple frontend scripts.
Now all timestamp and time calculation logic is centralized in one maintainable module.

Benefits:
- Single source of truth for time-related utilities
- Easier maintenance and updates
- Consistent behavior across all frontend components
- Better code organization and documentation
- Reduced bundle size through deduplication
2025-07-04 13:22:17 -04:00

723 lines
26 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) {
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;
}
}