Resolve merge conflicts between feature/css-optimization and main

- Resolved conflicts in admin.html to keep CSS optimization changes
- Regenerated package-lock.json after merge
- All features from both branches are now integrated
This commit is contained in:
Deco Vander 2025-07-04 14:28:50 -04:00
commit 9628da957b
18 changed files with 5638 additions and 386 deletions

View file

@ -91,20 +91,40 @@
</div>
<!-- Profanity Filter Tab -->
<div id="profanity-tab" class="tab-content profanity-management">
<div class="management-section">
<h4>🔒 Custom Profanity Words</h4>
<form id="add-profanity-form" class="profanity-form">
<input type="text" id="new-word" placeholder="Enter word to filter" required>
<select id="new-severity">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
</select>
<input type="text" id="new-category" placeholder="Category" value="custom">
<button type="submit">Add Word</button>
</form>
<div id="profanity-tab" class="tab-content">
<div class="form-section">
<h3>Custom Profanity Words</h3>
<!-- Add new word form -->
<div class="profanity-add-form">
<h4>Add Custom Word</h4>
<form id="add-profanity-form">
<div class="form-row">
<input type="text" id="new-word" placeholder="Enter word or phrase" required>
<select id="new-severity">
<option value="low">Low Severity</option>
<option value="medium" selected>Medium Severity</option>
<option value="high">High Severity</option>
</select>
<input type="text" id="new-category" placeholder="Category (optional)" value="custom">
<button type="submit">Add Word</button>
</div>
</form>
</div>
<!-- Test profanity filter -->
<div class="profanity-test-form">
<h4>Test Profanity Filter</h4>
<form id="test-profanity-form">
<div class="form-row">
<input type="text" id="test-text" placeholder="Enter text to test" required>
<button type="submit">Test Filter</button>
</div>
<div id="test-results" class="test-results"></div>
</form>
</div>
<!-- Custom words table -->
<table class="locations-table">
<thead>
<tr>
@ -123,15 +143,6 @@
</tbody>
</table>
</div>
<div class="management-section test-section">
<h4>🧪 Test Profanity Filter</h4>
<form id="test-profanity-form" class="test-form">
<textarea id="test-text" placeholder="Enter text to test for profanity..."></textarea>
<button type="submit">Test Text</button>
</form>
<div id="test-results" class="test-results empty">Enter text above to test profanity detection</div>
</div>
</div>
</div>
</div>
@ -144,6 +155,7 @@
</div>
</footer>
<script src="https://iceymi.b-cdn.net/admin.js"></script>
<script src="utils.js"></script>
<script src="admin.js"></script>
</body>
</html>

View file

@ -71,6 +71,53 @@ document.addEventListener('DOMContentLoaded', () => {
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';
@ -445,21 +492,159 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
function getTimeAgo(timestamp) {
const now = new Date();
const reportTime = new Date(timestamp);
const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60));
// getTimeAgo and parseUTCDate functions are now available from utils.js
// Profanity management functions
async function loadProfanityWords() {
if (!profanityTableBody) return;
if (diffInMinutes < 1) return 'just now';
if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
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;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
if (words.length === 0) {
profanityTableBody.innerHTML = '<tr><td colspan="6">No custom words added yet</td></tr>';
return;
}
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) return `${diffInDays} day${diffInDays !== 1 ? 's' : ''} ago`;
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;
return reportTime.toLocaleDateString();
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

View file

@ -82,19 +82,7 @@ document.addEventListener('DOMContentLoaded', async () => {
});
};
const 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`;
return 'over a day ago';
};
// getTimeAgo function is now available from utils.js
const refreshLocations = () => {
fetch('/api/locations')

View file

@ -93,19 +93,7 @@ document.addEventListener('DOMContentLoaded', async () => {
});
};
const 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`;
return 'over a day ago';
};
// getTimeAgo function is now available from utils.js
// Toggle between map and table view
const switchView = (viewType) => {
@ -165,45 +153,7 @@ document.addEventListener('DOMContentLoaded', async () => {
tableLocationCount.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`;
};
// Calculate time remaining until 24-hour expiration
const getTimeRemaining = (timestamp, persistent = false) => {
if (persistent) {
return 'Persistent';
}
const now = new Date();
const reportTime = new Date(timestamp);
const expirationTime = new Date(reportTime.getTime() + 24 * 60 * 60 * 1000);
const remaining = expirationTime - now;
if (remaining <= 0) return 'Expired';
const hours = Math.floor(remaining / (1000 * 60 * 60));
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
};
// Get CSS class for time remaining
const getRemainingClass = (timestamp, persistent = false) => {
if (persistent) {
return 'normal'; // Use normal styling for persistent reports
}
const now = new Date();
const reportTime = new Date(timestamp);
const expirationTime = new Date(reportTime.getTime() + 24 * 60 * 60 * 1000);
const remaining = expirationTime - now;
const hoursRemaining = remaining / (1000 * 60 * 60);
if (hoursRemaining <= 1) return 'urgent';
if (hoursRemaining <= 6) return 'warning';
return 'normal';
};
// getTimeRemaining and getRemainingClass functions are now available from utils.js
const refreshLocations = () => {
fetch('/api/locations')
@ -453,7 +403,14 @@ document.addEventListener('DOMContentLoaded', async () => {
}),
});
})
.then(res => res.json())
.then(res => {
if (!res.ok) {
return res.json().then(errorData => {
throw { status: res.status, data: errorData };
});
}
return res.json();
})
.then(location => {
refreshLocations();
messageDiv.textContent = 'Location reported successfully!';
@ -462,17 +419,34 @@ document.addEventListener('DOMContentLoaded', async () => {
})
.catch(err => {
console.error('Error reporting location:', err);
messageDiv.textContent = 'Error reporting location.';
messageDiv.className = 'message error';
// Handle specific profanity rejection
if (err.status === 400 && err.data && err.data.error === 'Submission rejected') {
messageDiv.textContent = err.data.message;
messageDiv.className = 'message error profanity-rejection';
// Clear the description field to encourage rewriting
document.getElementById('description').value = '';
document.getElementById('description').focus();
} else if (err.data && err.data.error) {
messageDiv.textContent = err.data.error;
messageDiv.className = 'message error';
} else {
messageDiv.textContent = 'Error reporting location. Please try again.';
messageDiv.className = 'message error';
}
})
.finally(() => {
submitBtn.disabled = false;
submitText.style.display = 'inline';
submitLoading.style.display = 'none';
messageDiv.style.display = 'block';
// Longer timeout for profanity rejection messages
const timeout = messageDiv.className.includes('profanity-rejection') ? 15000 : 3000;
setTimeout(() => {
messageDiv.style.display = 'none';
}, 3000);
}, timeout);
});
});

View file

@ -74,19 +74,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
};
const 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`;
return 'over a day ago';
};
// getTimeAgo function is now available from utils.js
const refreshLocations = () => {
fetch('/api/locations')

View file

@ -41,7 +41,8 @@ placeholder="Enter address, intersection (e.g., Main St & Second St, City), or l
<div class="form-group">
<label for="description">Additional Details (Optional)</label>
<textarea id="description" name="description" rows="3"
placeholder="Number of vehicles, time observed, etc."></textarea>
placeholder="Number of vehicles, time observed, etc." aria-describedby="description-help"></textarea>
<small id="description-help" class="input-help">Keep descriptions appropriate and relevant to road conditions. Submissions with inappropriate language will be rejected.</small>
</div>
<button type="submit" id="submit-btn">
@ -108,6 +109,7 @@ placeholder="Enter address, intersection (e.g., Main St & Second St, City), or l
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="utils.js"></script>
<script src="app-mapbox.js"></script>
</body>
</html>

96
public/utils.js Normal file
View file

@ -0,0 +1,96 @@
/**
* Shared utility functions for the Great Lakes Ice Report frontend
*/
/**
* Helper function to parse UTC date consistently across all frontend scripts
* Ensures timestamp is treated as UTC if it doesn't have timezone info
* @param {string} timestamp - The timestamp string to parse
* @returns {Date} - Parsed Date object with proper UTC interpretation
*/
const parseUTCDate = (timestamp) => {
return new Date(timestamp.includes('T') ? timestamp : timestamp + 'Z');
};
/**
* Calculate human-readable time ago string
* @param {string} timestamp - The timestamp to calculate from
* @returns {string} - Human-readable time difference
*/
const getTimeAgo = (timestamp) => {
const now = new Date();
const reportTime = parseUTCDate(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();
};
/**
* Calculate time remaining until 48-hour expiration
* @param {string} timestamp - The creation timestamp
* @param {boolean} persistent - Whether the report is persistent
* @returns {string} - Time remaining string
*/
const getTimeRemaining = (timestamp, persistent = false) => {
if (persistent) {
return 'Persistent';
}
const now = new Date();
const reportTime = parseUTCDate(timestamp);
const expirationTime = new Date(reportTime.getTime() + 48 * 60 * 60 * 1000);
const remaining = expirationTime - now;
if (remaining <= 0) return 'Expired';
const hours = Math.floor(remaining / (1000 * 60 * 60));
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
};
/**
* Get CSS class for time remaining styling
* @param {string} timestamp - The creation timestamp
* @param {boolean} persistent - Whether the report is persistent
* @returns {string} - CSS class name
*/
const getRemainingClass = (timestamp, persistent = false) => {
if (persistent) {
return 'normal'; // Use normal styling for persistent reports
}
const now = new Date();
const reportTime = parseUTCDate(timestamp);
const expirationTime = new Date(reportTime.getTime() + 48 * 60 * 60 * 1000);
const remaining = expirationTime - now;
const hoursRemaining = remaining / (1000 * 60 * 60);
if (hoursRemaining <= 1) return 'urgent';
if (hoursRemaining <= 6) return 'warning';
return 'normal';
};
// Export functions for module usage (if using ES6 modules in the future)
// For now, functions are available globally when script is included
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
parseUTCDate,
getTimeAgo,
getTimeRemaining,
getRemainingClass
};
}