mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 16:51:07 -05:00
725 lines
22 KiB
JavaScript
725 lines
22 KiB
JavaScript
/**
|
|
* 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;
|
|
} |