Add Road Trip Planner template with interactive map and trip management features

- Implemented a new HTML template for the Road Trip Planner.
- Integrated Leaflet.js for interactive mapping and routing.
- Added functionality for searching and selecting parks to include in a trip.
- Enabled drag-and-drop reordering of selected parks.
- Included trip optimization and route calculation features.
- Created a summary display for trip statistics.
- Added functionality to save trips and manage saved trips.
- Enhanced UI with responsive design and dark mode support.
This commit is contained in:
pacnpal
2025-08-15 20:53:00 -04:00
parent da7c7e3381
commit b5bae44cb8
99 changed files with 18697 additions and 4010 deletions

725
static/js/htmx-maps.js Normal file
View File

@@ -0,0 +1,725 @@
/**
* ThrillWiki HTMX Maps Integration - Dynamic Map Updates via HTMX
*
* This module handles HTMX events for map updates, manages loading states
* during API calls, updates map content based on HTMX responses, and provides
* error handling for failed requests
*/
class HTMXMapIntegration {
constructor(options = {}) {
this.options = {
mapInstance: null,
filterInstance: null,
defaultTarget: '#map-container',
loadingClass: 'htmx-loading',
errorClass: 'htmx-error',
successClass: 'htmx-success',
loadingTimeout: 30000, // 30 seconds
retryAttempts: 3,
retryDelay: 1000,
...options
};
this.loadingElements = new Set();
this.activeRequests = new Map();
this.requestQueue = [];
this.retryCount = new Map();
this.init();
}
/**
* Initialize HTMX integration
*/
init() {
if (typeof htmx === 'undefined') {
console.warn('HTMX not found, map integration disabled');
return;
}
this.setupEventHandlers();
this.setupCustomEvents();
this.setupErrorHandling();
this.enhanceExistingElements();
}
/**
* Setup HTMX event handlers
*/
setupEventHandlers() {
// Before request - show loading states
document.addEventListener('htmx:beforeRequest', (e) => {
this.handleBeforeRequest(e);
});
// After request - handle response and update maps
document.addEventListener('htmx:afterRequest', (e) => {
this.handleAfterRequest(e);
});
// Response error - handle failed requests
document.addEventListener('htmx:responseError', (e) => {
this.handleResponseError(e);
});
// Send error - handle network errors
document.addEventListener('htmx:sendError', (e) => {
this.handleSendError(e);
});
// Timeout - handle request timeouts
document.addEventListener('htmx:timeout', (e) => {
this.handleTimeout(e);
});
// Before swap - prepare for content updates
document.addEventListener('htmx:beforeSwap', (e) => {
this.handleBeforeSwap(e);
});
// After swap - update maps with new content
document.addEventListener('htmx:afterSwap', (e) => {
this.handleAfterSwap(e);
});
// Config request - modify requests before sending
document.addEventListener('htmx:configRequest', (e) => {
this.handleConfigRequest(e);
});
}
/**
* Setup custom map-specific events
*/
setupCustomEvents() {
// Custom event for map data updates
document.addEventListener('map:dataUpdate', (e) => {
this.handleMapDataUpdate(e);
});
// Custom event for filter changes
document.addEventListener('filter:changed', (e) => {
this.handleFilterChange(e);
});
// Custom event for search updates
document.addEventListener('search:results', (e) => {
this.handleSearchResults(e);
});
}
/**
* Setup global error handling
*/
setupErrorHandling() {
// Global error handler
window.addEventListener('error', (e) => {
if (e.filename && e.filename.includes('htmx')) {
console.error('HTMX error:', e.error);
this.showErrorMessage('An error occurred while updating the map');
}
});
// Unhandled promise rejection handler
window.addEventListener('unhandledrejection', (e) => {
if (e.reason && e.reason.toString().includes('htmx')) {
console.error('HTMX promise rejection:', e.reason);
this.showErrorMessage('Failed to complete map request');
}
});
}
/**
* Enhance existing elements with HTMX map functionality
*/
enhanceExistingElements() {
// Add map-specific attributes to filter forms
const filterForms = document.querySelectorAll('[data-map-filter]');
filterForms.forEach(form => {
if (!form.hasAttribute('hx-get')) {
form.setAttribute('hx-get', form.getAttribute('data-map-filter'));
form.setAttribute('hx-trigger', 'change, submit');
form.setAttribute('hx-target', '#map-container');
form.setAttribute('hx-swap', 'none');
}
});
// Add map update attributes to search inputs
const searchInputs = document.querySelectorAll('[data-map-search]');
searchInputs.forEach(input => {
if (!input.hasAttribute('hx-get')) {
input.setAttribute('hx-get', input.getAttribute('data-map-search'));
input.setAttribute('hx-trigger', 'input changed delay:500ms');
input.setAttribute('hx-target', '#search-results');
}
});
}
/**
* Handle before request event
*/
handleBeforeRequest(e) {
const element = e.target;
const requestId = this.generateRequestId();
// Store request information
this.activeRequests.set(requestId, {
element: element,
startTime: Date.now(),
url: e.detail.requestConfig.path
});
// Show loading state
this.showLoadingState(element, true);
// Add request ID to detail for tracking
e.detail.requestId = requestId;
// Set timeout
setTimeout(() => {
if (this.activeRequests.has(requestId)) {
this.handleTimeout({ detail: { requestId } });
}
}, this.options.loadingTimeout);
console.log('HTMX request started:', e.detail.requestConfig.path);
}
/**
* Handle after request event
*/
handleAfterRequest(e) {
const requestId = e.detail.requestId;
const request = this.activeRequests.get(requestId);
if (request) {
const duration = Date.now() - request.startTime;
console.log(`HTMX request completed in ${duration}ms:`, request.url);
this.activeRequests.delete(requestId);
this.showLoadingState(request.element, false);
}
if (e.detail.successful) {
this.handleSuccessfulResponse(e);
} else {
this.handleFailedResponse(e);
}
}
/**
* Handle successful response
*/
handleSuccessfulResponse(e) {
const element = e.target;
// Add success class temporarily
element.classList.add(this.options.successClass);
setTimeout(() => {
element.classList.remove(this.options.successClass);
}, 2000);
// Reset retry count
this.retryCount.delete(element);
// Check if this is a map-related request
if (this.isMapRequest(e)) {
this.updateMapFromResponse(e);
}
}
/**
* Handle failed response
*/
handleFailedResponse(e) {
const element = e.target;
// Add error class
element.classList.add(this.options.errorClass);
setTimeout(() => {
element.classList.remove(this.options.errorClass);
}, 5000);
// Check if we should retry
if (this.shouldRetry(element)) {
this.scheduleRetry(element, e.detail);
} else {
this.showErrorMessage('Failed to update map data');
}
}
/**
* Handle response error
*/
handleResponseError(e) {
console.error('HTMX response error:', e.detail);
const element = e.target;
const status = e.detail.xhr.status;
let message = 'An error occurred while updating the map';
switch (status) {
case 400:
message = 'Invalid request parameters';
break;
case 401:
message = 'Authentication required';
break;
case 403:
message = 'Access denied';
break;
case 404:
message = 'Map data not found';
break;
case 429:
message = 'Too many requests. Please wait a moment.';
break;
case 500:
message = 'Server error. Please try again later.';
break;
}
this.showErrorMessage(message);
this.showLoadingState(element, false);
}
/**
* Handle send error
*/
handleSendError(e) {
console.error('HTMX send error:', e.detail);
this.showErrorMessage('Network error. Please check your connection.');
this.showLoadingState(e.target, false);
}
/**
* Handle timeout
*/
handleTimeout(e) {
console.warn('HTMX request timeout');
if (e.detail.requestId) {
const request = this.activeRequests.get(e.detail.requestId);
if (request) {
this.showLoadingState(request.element, false);
this.activeRequests.delete(e.detail.requestId);
}
}
this.showErrorMessage('Request timed out. Please try again.');
}
/**
* Handle before swap
*/
handleBeforeSwap(e) {
// Prepare map for content update
if (this.isMapRequest(e)) {
console.log('Preparing map for content swap');
}
}
/**
* Handle after swap
*/
handleAfterSwap(e) {
// Re-initialize any new HTMX elements
this.enhanceExistingElements();
// Update maps if needed
if (this.isMapRequest(e)) {
this.reinitializeMapComponents();
}
}
/**
* Handle config request
*/
handleConfigRequest(e) {
const config = e.detail;
// Add CSRF token if available
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (csrfToken && (config.verb === 'post' || config.verb === 'put' || config.verb === 'patch')) {
config.headers['X-CSRFToken'] = csrfToken.value;
}
// Add map-specific headers
if (this.isMapRequest(e)) {
config.headers['X-Map-Request'] = 'true';
// Add current map bounds if available
if (this.options.mapInstance) {
const bounds = this.options.mapInstance.getBounds();
if (bounds) {
config.headers['X-Map-Bounds'] = JSON.stringify({
north: bounds.getNorth(),
south: bounds.getSouth(),
east: bounds.getEast(),
west: bounds.getWest(),
zoom: this.options.mapInstance.getZoom()
});
}
}
}
}
/**
* Handle map data updates
*/
handleMapDataUpdate(e) {
if (this.options.mapInstance) {
const data = e.detail;
this.options.mapInstance.updateMarkers(data);
}
}
/**
* Handle filter changes
*/
handleFilterChange(e) {
if (this.options.filterInstance) {
const filters = e.detail;
// Trigger HTMX request for filter update
const filterForm = document.getElementById('map-filters');
if (filterForm && filterForm.hasAttribute('hx-get')) {
htmx.trigger(filterForm, 'change');
}
}
}
/**
* Handle search results
*/
handleSearchResults(e) {
const results = e.detail;
// Update map with search results if applicable
if (results.locations && this.options.mapInstance) {
this.options.mapInstance.updateMarkers({ locations: results.locations });
}
}
/**
* Show/hide loading state
*/
showLoadingState(element, show) {
if (show) {
element.classList.add(this.options.loadingClass);
this.loadingElements.add(element);
// Show loading indicators
const indicators = element.querySelectorAll('.htmx-indicator');
indicators.forEach(indicator => {
indicator.style.display = 'block';
});
// Disable form elements
const inputs = element.querySelectorAll('input, button, select');
inputs.forEach(input => {
input.disabled = true;
});
} else {
element.classList.remove(this.options.loadingClass);
this.loadingElements.delete(element);
// Hide loading indicators
const indicators = element.querySelectorAll('.htmx-indicator');
indicators.forEach(indicator => {
indicator.style.display = 'none';
});
// Re-enable form elements
const inputs = element.querySelectorAll('input, button, select');
inputs.forEach(input => {
input.disabled = false;
});
}
}
/**
* Check if request is map-related
*/
isMapRequest(e) {
const element = e.target;
const url = e.detail.requestConfig ? e.detail.requestConfig.path : '';
return element.hasAttribute('data-map-filter') ||
element.hasAttribute('data-map-search') ||
element.closest('[data-map-target]') ||
url.includes('/api/map/') ||
url.includes('/maps/');
}
/**
* Update map from HTMX response
*/
updateMapFromResponse(e) {
if (!this.options.mapInstance) return;
try {
// Try to extract map data from response
const responseText = e.detail.xhr.responseText;
// If response is JSON, update map directly
try {
const data = JSON.parse(responseText);
if (data.status === 'success' && data.data) {
this.options.mapInstance.updateMarkers(data.data);
}
} catch (jsonError) {
// If not JSON, look for data attributes in HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = responseText;
const mapData = tempDiv.querySelector('[data-map-data]');
if (mapData) {
const data = JSON.parse(mapData.getAttribute('data-map-data'));
this.options.mapInstance.updateMarkers(data);
}
}
} catch (error) {
console.error('Failed to update map from response:', error);
}
}
/**
* Check if element should be retried
*/
shouldRetry(element) {
const retryCount = this.retryCount.get(element) || 0;
return retryCount < this.options.retryAttempts;
}
/**
* Schedule retry for failed request
*/
scheduleRetry(element, detail) {
const retryCount = (this.retryCount.get(element) || 0) + 1;
this.retryCount.set(element, retryCount);
const delay = this.options.retryDelay * Math.pow(2, retryCount - 1); // Exponential backoff
setTimeout(() => {
console.log(`Retrying HTMX request (attempt ${retryCount})`);
htmx.trigger(element, 'retry');
}, delay);
}
/**
* Show error message to user
*/
showErrorMessage(message) {
// Create or update error message element
let errorEl = document.getElementById('htmx-error-message');
if (!errorEl) {
errorEl = document.createElement('div');
errorEl.id = 'htmx-error-message';
errorEl.className = 'htmx-error-message';
// Insert at top of page
document.body.insertBefore(errorEl, document.body.firstChild);
}
errorEl.innerHTML = `
<div class="error-content">
<i class="fas fa-exclamation-circle"></i>
<span>${message}</span>
<button onclick="this.parentElement.parentElement.remove()" class="error-close">
<i class="fas fa-times"></i>
</button>
</div>
`;
errorEl.style.display = 'block';
// Auto-hide after 10 seconds
setTimeout(() => {
if (errorEl.parentNode) {
errorEl.remove();
}
}, 10000);
}
/**
* Reinitialize map components after content swap
*/
reinitializeMapComponents() {
// Reinitialize filter components
if (this.options.filterInstance) {
this.options.filterInstance.init();
}
// Reinitialize any new map containers
const newMapContainers = document.querySelectorAll('[data-map="auto"]:not([data-initialized])');
newMapContainers.forEach(container => {
container.setAttribute('data-initialized', 'true');
// Initialize new map instance if needed
});
}
/**
* Generate unique request ID
*/
generateRequestId() {
return `htmx-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Connect to map instance
*/
connectToMap(mapInstance) {
this.options.mapInstance = mapInstance;
}
/**
* Connect to filter instance
*/
connectToFilter(filterInstance) {
this.options.filterInstance = filterInstance;
}
/**
* Get active request count
*/
getActiveRequestCount() {
return this.activeRequests.size;
}
/**
* Cancel all active requests
*/
cancelAllRequests() {
this.activeRequests.forEach((request, id) => {
this.showLoadingState(request.element, false);
});
this.activeRequests.clear();
}
/**
* Get loading elements
*/
getLoadingElements() {
return Array.from(this.loadingElements);
}
}
// Auto-initialize HTMX integration
document.addEventListener('DOMContentLoaded', function() {
window.htmxMapIntegration = new HTMXMapIntegration();
// Connect to existing instances
if (window.thrillwikiMap) {
window.htmxMapIntegration.connectToMap(window.thrillwikiMap);
}
if (window.mapFilters) {
window.htmxMapIntegration.connectToFilter(window.mapFilters);
}
});
// Add styles for HTMX integration
document.addEventListener('DOMContentLoaded', function() {
if (document.getElementById('htmx-map-styles')) return;
const styles = `
<style id="htmx-map-styles">
.htmx-loading {
opacity: 0.7;
pointer-events: none;
}
.htmx-error {
border-color: #EF4444;
background-color: #FEE2E2;
}
.htmx-success {
border-color: #10B981;
background-color: #D1FAE5;
}
.htmx-indicator {
display: none;
}
.htmx-error-message {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
max-width: 400px;
background: #FEE2E2;
border: 1px solid #FCA5A5;
border-radius: 8px;
padding: 0;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
animation: slideInRight 0.3s ease;
}
.error-content {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
color: #991B1B;
font-size: 14px;
}
.error-close {
background: none;
border: none;
color: #991B1B;
cursor: pointer;
padding: 4px;
margin-left: auto;
}
.error-close:hover {
color: #7F1D1D;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Dark mode styles */
.dark .htmx-error-message {
background: #7F1D1D;
border-color: #991B1B;
}
.dark .error-content {
color: #FCA5A5;
}
.dark .error-close {
color: #FCA5A5;
}
.dark .error-close:hover {
color: #F87171;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = HTMXMapIntegration;
} else {
window.HTMXMapIntegration = HTMXMapIntegration;
}